Skip to main content

risingwave_connector/connector_common/iceberg/
jni_catalog.rs

1// Copyright 2024 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
15//! This module provide jni catalog.
16
17#![expect(
18    clippy::disallowed_types,
19    reason = "construct iceberg::Error to implement the trait"
20)]
21
22use std::collections::HashMap;
23use std::fmt::Debug;
24use std::sync::Arc;
25
26use anyhow::Context;
27use async_trait::async_trait;
28use iceberg::io::FileIO;
29use iceberg::spec::{Schema, SortOrder, TableMetadata, UnboundPartitionSpec};
30use iceberg::table::Table;
31use iceberg::{
32    Catalog, Namespace, NamespaceIdent, TableCommit, TableCreation, TableIdent, TableRequirement,
33    TableUpdate,
34};
35use itertools::Itertools;
36use jni::objects::{GlobalRef, JObject};
37use risingwave_common::global_jvm::Jvm;
38use risingwave_jni_core::call_method;
39use risingwave_jni_core::jvm_runtime::{execute_with_jni_env, jobj_to_str};
40use serde::{Deserialize, Serialize};
41use thiserror_ext::AsReport;
42
43use crate::error::ConnectorResult;
44
45#[derive(Debug, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47struct LoadTableResponse {
48    pub metadata_location: Option<String>,
49    pub metadata: TableMetadata,
50    pub _config: Option<HashMap<String, String>>,
51}
52
53#[derive(Debug, Serialize)]
54#[serde(rename_all = "kebab-case")]
55struct CreateTableRequest {
56    /// The name of the table.
57    pub name: String,
58    /// The location of the table.
59    pub location: Option<String>,
60    /// The schema of the table.
61    pub schema: Schema,
62    /// The partition spec of the table, could be None.
63    pub partition_spec: Option<UnboundPartitionSpec>,
64    /// The sort order of the table.
65    pub write_order: Option<SortOrder>,
66    /// The properties of the table.
67    pub properties: HashMap<String, String>,
68}
69
70#[derive(Debug, Serialize, Deserialize)]
71struct CommitTableRequest {
72    identifier: TableIdent,
73    requirements: Vec<TableRequirement>,
74    updates: Vec<TableUpdate>,
75}
76
77#[derive(Debug, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79struct CommitTableResponse {
80    metadata_location: String,
81    metadata: TableMetadata,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case")]
86struct ListNamespacesResponse {
87    namespaces: Vec<NamespaceIdent>,
88    next_page_token: Option<String>,
89}
90
91#[derive(Debug, Serialize, Deserialize)]
92#[serde(rename_all = "kebab-case")]
93struct ListTablesResponse {
94    identifiers: Vec<TableIdent>,
95    next_page_token: Option<String>,
96}
97
98impl From<&TableCreation> for CreateTableRequest {
99    fn from(value: &TableCreation) -> Self {
100        Self {
101            name: value.name.clone(),
102            location: value.location.clone(),
103            schema: value.schema.clone(),
104            partition_spec: value.partition_spec.clone(),
105            write_order: value.sort_order.clone(),
106            properties: value.properties.clone(),
107        }
108    }
109}
110
111fn namespace_to_string(namespace: &NamespaceIdent) -> String {
112    namespace.iter().join(".")
113}
114
115#[derive(Debug)]
116struct JniCatalogInner {
117    java_catalog: GlobalRef,
118    jvm: Jvm,
119}
120
121#[derive(Debug)]
122pub struct JniCatalog {
123    /// A blocking JNI operation can outlive its async caller after cancellation. Keep the Java
124    /// catalog alive until the last such operation finishes.
125    inner: Arc<JniCatalogInner>,
126    file_io_props: Arc<HashMap<String, String>>,
127}
128
129/// Iceberg's Java catalog API is synchronous and may perform remote catalog I/O. Running it
130/// directly in an async `Catalog` method would block a Tokio worker thread, so async JNI operations
131/// go through the runtime's blocking pool.
132async fn execute_blocking_jni<T>(
133    task: impl FnOnce() -> anyhow::Result<T> + Send + 'static,
134) -> anyhow::Result<T>
135where
136    T: Send + 'static,
137{
138    tokio::task::spawn_blocking(task)
139        .await
140        .context("Failed to join blocking Iceberg JNI catalog task")?
141}
142
143#[async_trait]
144impl Catalog for JniCatalog {
145    /// List namespaces from the catalog.
146    async fn list_namespaces(
147        &self,
148        _parent: Option<&NamespaceIdent>,
149    ) -> iceberg::Result<Vec<NamespaceIdent>> {
150        let inner = self.inner.clone();
151        execute_blocking_jni(move || {
152            execute_with_jni_env(inner.jvm, |env| {
153                let result_json =
154                    call_method!(env, inner.java_catalog.as_obj(), {String listNamespaces()})
155                        .with_context(|| "Failed to list iceberg namespaces".to_owned())?;
156
157                let rust_json_str = jobj_to_str(env, result_json)?;
158
159                let resp: ListNamespacesResponse = serde_json::from_str(&rust_json_str)?;
160
161                Ok(resp.namespaces)
162            })
163        })
164        .await
165        .map_err(|e| {
166            iceberg::Error::new(
167                iceberg::ErrorKind::Unexpected,
168                "Failed to list iceberg namespaces.",
169            )
170            .with_source(e)
171        })
172    }
173
174    /// Create a new namespace inside the catalog.
175    async fn create_namespace(
176        &self,
177        namespace: &iceberg::NamespaceIdent,
178        _properties: HashMap<String, String>,
179    ) -> iceberg::Result<iceberg::Namespace> {
180        let inner = self.inner.clone();
181        let namespace = namespace.clone();
182        execute_blocking_jni(move || {
183            execute_with_jni_env(inner.jvm, |env| {
184                let namespace_str = namespace_to_string(&namespace);
185                let namespace_jstr = env.new_string(&namespace_str).unwrap();
186
187                call_method!(env, inner.java_catalog.as_obj(), {void createNamespace(String)},
188                    &namespace_jstr)
189                .with_context(|| format!("Failed to create namespace: {namespace}"))?;
190
191                Ok(Namespace::new(namespace))
192            })
193        })
194        .await
195        .map_err(|e| {
196            iceberg::Error::new(
197                iceberg::ErrorKind::Unexpected,
198                "Failed to create namespace.",
199            )
200            .with_source(e)
201        })
202    }
203
204    /// Get a namespace information from the catalog.
205    async fn get_namespace(&self, _namespace: &NamespaceIdent) -> iceberg::Result<Namespace> {
206        todo!()
207    }
208
209    /// Check if namespace exists in catalog.
210    async fn namespace_exists(&self, namespace: &NamespaceIdent) -> iceberg::Result<bool> {
211        let inner = self.inner.clone();
212        let namespace = namespace.clone();
213        execute_blocking_jni(move || {
214            execute_with_jni_env(inner.jvm, |env| {
215                let namespace_str = namespace_to_string(&namespace);
216                let namespace_jstr = env.new_string(&namespace_str).unwrap();
217
218                let exists =
219                    call_method!(env, inner.java_catalog.as_obj(), {boolean namespaceExists(String)},
220                    &namespace_jstr)
221                    .with_context(|| format!("Failed to check namespace exists: {namespace}"))?;
222
223                Ok(exists)
224            })
225        })
226        .await
227        .map_err(|e| {
228            iceberg::Error::new(
229                iceberg::ErrorKind::Unexpected,
230                "Failed to check namespace exists.",
231            )
232            .with_source(e)
233        })
234    }
235
236    /// Drop a namespace from the catalog.
237    async fn drop_namespace(&self, _namespace: &NamespaceIdent) -> iceberg::Result<()> {
238        todo!()
239    }
240
241    /// List tables from namespace.
242    async fn list_tables(&self, namespace: &NamespaceIdent) -> iceberg::Result<Vec<TableIdent>> {
243        let inner = self.inner.clone();
244        let namespace = namespace.clone();
245        execute_blocking_jni(move || {
246            execute_with_jni_env(inner.jvm, |env| {
247                let namespace_str = namespace_to_string(&namespace);
248                let namespace_jstr = env.new_string(&namespace_str).unwrap();
249
250                let result_json =
251                    call_method!(env, inner.java_catalog.as_obj(), {String listTables(String)},
252                    &namespace_jstr)
253                    .with_context(|| {
254                        format!("Failed to list iceberg tables in namespace: {}", namespace)
255                    })?;
256
257                let rust_json_str = jobj_to_str(env, result_json)?;
258
259                let resp: ListTablesResponse = serde_json::from_str(&rust_json_str)?;
260
261                Ok(resp.identifiers)
262            })
263        })
264        .await
265        .map_err(|e| {
266            iceberg::Error::new(
267                iceberg::ErrorKind::Unexpected,
268                "Failed to list iceberg  tables.",
269            )
270            .with_source(e)
271        })
272    }
273
274    async fn update_namespace(
275        &self,
276        _namespace: &NamespaceIdent,
277        _properties: HashMap<String, String>,
278    ) -> iceberg::Result<()> {
279        todo!()
280    }
281
282    /// Create a new table inside the namespace.
283    async fn create_table(
284        &self,
285        namespace: &NamespaceIdent,
286        creation: TableCreation,
287    ) -> iceberg::Result<Table> {
288        let inner = self.inner.clone();
289        let file_io_props = self.file_io_props.clone();
290        let namespace = namespace.clone();
291        execute_blocking_jni(move || {
292            execute_with_jni_env(inner.jvm, |env| {
293                let namespace_str = namespace_to_string(&namespace);
294                let namespace_jstr = env.new_string(&namespace_str).unwrap();
295
296                let creation_str = serde_json::to_string(&CreateTableRequest::from(&creation))?;
297
298                let creation_jstr = env.new_string(&creation_str).unwrap();
299
300                let result_json =
301                    call_method!(env, inner.java_catalog.as_obj(), {String createTable(String, String)},
302                    &namespace_jstr, &creation_jstr)
303                    .with_context(|| {
304                        format!("Failed to create iceberg table: {}", creation.name)
305                    })?;
306
307                let rust_json_str = jobj_to_str(env, result_json)?;
308
309                let resp: LoadTableResponse = serde_json::from_str(&rust_json_str)?;
310
311                let metadata_location = resp.metadata_location.ok_or_else(|| {
312                    iceberg::Error::new(
313                        iceberg::ErrorKind::FeatureUnsupported,
314                        "Loading uncommitted table is not supported!",
315                    )
316                })?;
317
318                let table_metadata = resp.metadata;
319
320                let file_io = FileIO::from_path(&metadata_location)?
321                    .with_props(file_io_props.iter())
322                    .build()?;
323
324                Ok(Table::builder()
325                    .file_io(file_io)
326                    .identifier(TableIdent::new(namespace, creation.name))
327                    .metadata(table_metadata)
328                    .build())
329            })
330        })
331        .await
332        .map_err(|e| {
333            iceberg::Error::new(
334                iceberg::ErrorKind::Unexpected,
335                "Failed to create iceberg table.",
336            )
337            .with_source(e)
338        })?
339    }
340
341    /// Load table from the catalog.
342    async fn load_table(&self, table: &TableIdent) -> iceberg::Result<Table> {
343        let inner = self.inner.clone();
344        let file_io_props = self.file_io_props.clone();
345        let table = table.clone();
346        execute_blocking_jni(move || {
347            execute_with_jni_env(inner.jvm, |env| {
348                let table_name_str = table.to_string();
349
350                let table_name_jstr = env.new_string(&table_name_str).unwrap();
351
352                let result_json =
353                    call_method!(env, inner.java_catalog.as_obj(), {String loadTable(String)},
354                    &table_name_jstr)
355                    .with_context(|| format!("Failed to load iceberg table: {table_name_str}"))?;
356
357                let rust_json_str = jobj_to_str(env, result_json)?;
358
359                let resp: LoadTableResponse = serde_json::from_str(&rust_json_str)?;
360
361                let metadata_location = resp.metadata_location.ok_or_else(|| {
362                    iceberg::Error::new(
363                        iceberg::ErrorKind::FeatureUnsupported,
364                        "Loading uncommitted table is not supported!",
365                    )
366                })?;
367
368                tracing::info!(
369                    "Table metadata location of {table_name_str} is {metadata_location}"
370                );
371
372                let table_metadata = resp.metadata;
373
374                let file_io = FileIO::from_path(&metadata_location)?
375                    .with_props(file_io_props.iter())
376                    .build()?;
377
378                Ok(Table::builder()
379                    .file_io(file_io)
380                    .identifier(table)
381                    .metadata(table_metadata)
382                    .build())
383            })
384        })
385        .await
386        .map_err(|e| {
387            iceberg::Error::new(
388                iceberg::ErrorKind::Unexpected,
389                "Failed to load iceberg table.",
390            )
391            .with_source(e)
392        })?
393    }
394
395    /// Drop a table from the catalog.
396    async fn drop_table(&self, table: &TableIdent) -> iceberg::Result<()> {
397        let inner = self.inner.clone();
398        let table = table.to_owned();
399        execute_blocking_jni(move || {
400            execute_with_jni_env(inner.jvm, |env| {
401                let table_name_str = table.to_string();
402
403                let table_name_jstr = env.new_string(&table_name_str).unwrap();
404
405                call_method!(env, inner.java_catalog.as_obj(), {boolean dropTable(String)},
406                &table_name_jstr)
407                .with_context(|| format!("Failed to drop iceberg table: {table_name_str}"))?;
408
409                Ok(())
410            })
411        })
412        .await
413        .map_err(|e| {
414            iceberg::Error::new(
415                iceberg::ErrorKind::Unexpected,
416                "Failed to drop iceberg table.",
417            )
418            .with_source(e)
419        })
420    }
421
422    async fn register_table(
423        &self,
424        _table_ident: &TableIdent,
425        _metadata_location: String,
426    ) -> iceberg::Result<Table> {
427        Err(iceberg::Error::new(
428            iceberg::ErrorKind::Unexpected,
429            "register_table is not supported by JniCatalog",
430        ))
431    }
432
433    /// Check if a table exists in the catalog.
434    async fn table_exists(&self, table: &TableIdent) -> iceberg::Result<bool> {
435        let inner = self.inner.clone();
436        let table = table.clone();
437        execute_blocking_jni(move || {
438            execute_with_jni_env(inner.jvm, |env| {
439                let table_name_str = table.to_string();
440
441                let table_name_jstr = env.new_string(&table_name_str).unwrap();
442
443                let exists =
444                    call_method!(env, inner.java_catalog.as_obj(), {boolean tableExists(String)},
445                    &table_name_jstr)
446                    .with_context(|| {
447                        format!("Failed to check iceberg table exists: {table_name_str}")
448                    })?;
449
450                Ok(exists)
451            })
452        })
453        .await
454        .map_err(|e| {
455            iceberg::Error::new(
456                iceberg::ErrorKind::Unexpected,
457                "Failed to check iceberg table exists.",
458            )
459            .with_source(e)
460        })
461    }
462
463    /// Rename a table in the catalog.
464    async fn rename_table(&self, _src: &TableIdent, _dest: &TableIdent) -> iceberg::Result<()> {
465        todo!()
466    }
467
468    /// Update a table to the catalog.
469    async fn update_table(&self, mut commit: TableCommit) -> iceberg::Result<Table> {
470        let inner = self.inner.clone();
471        let file_io_props = self.file_io_props.clone();
472        execute_blocking_jni(move || {
473            execute_with_jni_env(inner.jvm, |env| {
474                let requirements = commit.take_requirements();
475                let updates = commit.take_updates();
476                let request = CommitTableRequest {
477                    identifier: commit.identifier().clone(),
478                    requirements,
479                    updates,
480                };
481                let request_str = serde_json::to_string(&request)?;
482
483                let request_jni_str = env.new_string(&request_str).with_context(|| {
484                    format!("Failed to create jni string from request json: {request_str}.")
485                })?;
486
487                let result_json =
488                    call_method!(env, inner.java_catalog.as_obj(), {String updateTable(String)},
489                    &request_jni_str)
490                    .with_context(|| {
491                        format!("Failed to update iceberg table: {}", commit.identifier())
492                    })?;
493
494                let rust_json_str = jobj_to_str(env, result_json)?;
495
496                let response: CommitTableResponse = serde_json::from_str(&rust_json_str)?;
497
498                tracing::info!(
499                    "Table metadata location of {} is {}",
500                    commit.identifier(),
501                    response.metadata_location
502                );
503
504                let table_metadata = response.metadata;
505
506                let file_io = FileIO::from_path(&response.metadata_location)?
507                    .with_props(file_io_props.iter())
508                    .build()?;
509
510                Ok(Table::builder()
511                    .file_io(file_io)
512                    .identifier(commit.identifier().clone())
513                    .metadata(table_metadata)
514                    .build()?)
515            })
516        })
517        .await
518        .map_err(|e| {
519            iceberg::Error::new(
520                iceberg::ErrorKind::Unexpected,
521                "Failed to update iceberg table.",
522            )
523            .with_source(e)
524        })
525    }
526}
527
528impl Drop for JniCatalogInner {
529    fn drop(&mut self) {
530        let _ = execute_with_jni_env(self.jvm, |env| {
531            call_method!(env, self.java_catalog.as_obj(), {void close()})
532                .with_context(|| "Failed to close iceberg catalog".to_owned())?;
533            Ok(())
534        })
535        .inspect_err(
536            |e| tracing::error!(error = ?e.as_report(), "Failed to close iceberg catalog"),
537        );
538    }
539}
540
541impl JniCatalog {
542    fn build(
543        file_io_props: HashMap<String, String>,
544        name: impl ToString,
545        catalog_impl: impl ToString,
546        java_catalog_props: HashMap<String, String>,
547    ) -> ConnectorResult<Self> {
548        let jvm = Jvm::get_or_init()?;
549
550        execute_with_jni_env(jvm, |env| {
551            // Convert props to string array
552            let props = env.new_object_array(
553                (java_catalog_props.len() * 2) as i32,
554                "java/lang/String",
555                JObject::null(),
556            )?;
557            for (i, (key, value)) in java_catalog_props.iter().enumerate() {
558                let key_j_str = env.new_string(key)?;
559                let value_j_str = env.new_string(value)?;
560                env.set_object_array_element(&props, i as i32 * 2, key_j_str)?;
561                env.set_object_array_element(&props, i as i32 * 2 + 1, value_j_str)?;
562            }
563
564            let jni_catalog_wrapper = env
565                .call_static_method(
566                    "com/risingwave/connector/catalog/JniCatalogWrapper",
567                    "create",
568                    "(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;)Lcom/risingwave/connector/catalog/JniCatalogWrapper;",
569                    &[
570                        (&env.new_string(name.to_string()).unwrap()).into(),
571                        (&env.new_string(catalog_impl.to_string()).unwrap()).into(),
572                        (&props).into(),
573                    ],
574                )?;
575
576            let jni_catalog = env.new_global_ref(jni_catalog_wrapper.l().unwrap())?;
577
578            Ok(Self {
579                inner: Arc::new(JniCatalogInner {
580                    java_catalog: jni_catalog,
581                    jvm,
582                }),
583                file_io_props: Arc::new(file_io_props),
584            })
585        })
586            .map_err(Into::into)
587    }
588
589    pub async fn build_catalog(
590        file_io_props: HashMap<String, String>,
591        name: impl ToString + Send + 'static,
592        catalog_impl: impl ToString + Send + 'static,
593        java_catalog_props: HashMap<String, String>,
594    ) -> ConnectorResult<Arc<dyn Catalog>> {
595        let catalog = execute_blocking_jni(move || {
596            Ok(Self::build(
597                file_io_props,
598                name,
599                catalog_impl,
600                java_catalog_props,
601            )?)
602        })
603        .await?;
604        Ok(Arc::new(catalog) as Arc<dyn Catalog>)
605    }
606}