@databricks/zerobus-ingest-sdk 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +189 -115
- package/Cargo.toml +14 -21
- package/LICENSE +1 -1
- package/README.md +151 -208
- package/index.d.ts +82 -38
- package/package.json +30 -14
- package/src/headers_provider.ts +24 -36
- package/src/lib.rs +410 -310
- package/utils/descriptor.d.ts +32 -0
- package/utils/descriptor.js +55 -0
- package/utils/descriptor.ts +2 -2
package/src/lib.rs
CHANGED
|
@@ -13,22 +13,18 @@
|
|
|
13
13
|
#![deny(clippy::all)]
|
|
14
14
|
|
|
15
15
|
use napi::bindgen_prelude::*;
|
|
16
|
-
use napi::threadsafe_function::{
|
|
17
|
-
use napi::{Env,
|
|
16
|
+
use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction};
|
|
17
|
+
use napi::{Env, JsFunction, JsGlobal, JsObject, JsString, JsUnknown, ValueType};
|
|
18
18
|
use napi_derive::napi;
|
|
19
19
|
|
|
20
|
+
use async_trait::async_trait;
|
|
21
|
+
use databricks_zerobus_ingest_sdk::databricks::zerobus::RecordType as RustRecordType;
|
|
20
22
|
use databricks_zerobus_ingest_sdk::{
|
|
21
|
-
EncodedRecord as RustRecordPayload,
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
DefaultTokenFactory, EncodedRecord as RustRecordPayload,
|
|
24
|
+
HeadersProvider as RustHeadersProvider, ZerobusError as RustZerobusError,
|
|
25
|
+
ZerobusResult as RustZerobusResult, ZerobusSdk as RustZerobusSdk,
|
|
24
26
|
ZerobusStream as RustZerobusStream,
|
|
25
|
-
HeadersProvider as RustHeadersProvider,
|
|
26
|
-
ZerobusResult as RustZerobusResult,
|
|
27
|
-
ZerobusError as RustZerobusError,
|
|
28
|
-
DefaultTokenFactory,
|
|
29
27
|
};
|
|
30
|
-
use databricks_zerobus_ingest_sdk::databricks::zerobus::RecordType as RustRecordType;
|
|
31
|
-
use async_trait::async_trait;
|
|
32
28
|
use prost_types;
|
|
33
29
|
use std::collections::HashMap;
|
|
34
30
|
use std::sync::Arc;
|
|
@@ -54,7 +50,7 @@ pub enum RecordType {
|
|
|
54
50
|
#[napi(object)]
|
|
55
51
|
pub struct StreamConfigurationOptions {
|
|
56
52
|
/// Maximum number of unacknowledged requests that can be in flight.
|
|
57
|
-
/// Default:
|
|
53
|
+
/// Default: 1,000,000
|
|
58
54
|
pub max_inflight_requests: Option<u32>,
|
|
59
55
|
|
|
60
56
|
/// Enable automatic stream recovery on transient failures.
|
|
@@ -96,32 +92,6 @@ pub struct StreamConfigurationOptions {
|
|
|
96
92
|
pub stream_paused_max_wait_time_ms: Option<u32>,
|
|
97
93
|
}
|
|
98
94
|
|
|
99
|
-
impl From<StreamConfigurationOptions> for RustStreamOptions {
|
|
100
|
-
fn from(opts: StreamConfigurationOptions) -> Self {
|
|
101
|
-
let default = RustStreamOptions::default();
|
|
102
|
-
|
|
103
|
-
let record_type = match opts.record_type {
|
|
104
|
-
Some(0) => RustRecordType::Json,
|
|
105
|
-
Some(1) => RustRecordType::Proto,
|
|
106
|
-
_ => RustRecordType::Proto,
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
RustStreamOptions {
|
|
110
|
-
max_inflight_requests: opts.max_inflight_requests.unwrap_or(default.max_inflight_requests as u32) as usize,
|
|
111
|
-
recovery: opts.recovery.unwrap_or(default.recovery),
|
|
112
|
-
recovery_timeout_ms: opts.recovery_timeout_ms.map(|v| v as u64).unwrap_or(default.recovery_timeout_ms),
|
|
113
|
-
recovery_backoff_ms: opts.recovery_backoff_ms.map(|v| v as u64).unwrap_or(default.recovery_backoff_ms),
|
|
114
|
-
recovery_retries: opts.recovery_retries.unwrap_or(default.recovery_retries),
|
|
115
|
-
flush_timeout_ms: opts.flush_timeout_ms.map(|v| v as u64).unwrap_or(default.flush_timeout_ms),
|
|
116
|
-
server_lack_of_ack_timeout_ms: opts.server_lack_of_ack_timeout_ms.map(|v| v as u64).unwrap_or(default.server_lack_of_ack_timeout_ms),
|
|
117
|
-
record_type,
|
|
118
|
-
callback_max_wait_time_ms: None, // Callbacks not supported in TS SDK
|
|
119
|
-
stream_paused_max_wait_time_ms: opts.stream_paused_max_wait_time_ms.map(|v| v as u64),
|
|
120
|
-
ack_callback: None, // Callbacks not supported in TS SDK
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
95
|
/// Properties of the target Delta table for ingestion.
|
|
126
96
|
///
|
|
127
97
|
/// Specifies which Unity Catalog table to write to and optionally the schema descriptor
|
|
@@ -133,31 +103,11 @@ pub struct TableProperties {
|
|
|
133
103
|
pub table_name: String,
|
|
134
104
|
|
|
135
105
|
/// Optional Protocol Buffer descriptor as a base64-encoded string.
|
|
136
|
-
///
|
|
106
|
+
/// Omitting this does not select JSON. The stream defaults to Protocol Buffers
|
|
107
|
+
/// unless `record_type` is set to JSON.
|
|
137
108
|
pub descriptor_proto: Option<String>,
|
|
138
109
|
}
|
|
139
110
|
|
|
140
|
-
impl TableProperties {
|
|
141
|
-
fn to_rust(&self) -> Result<RustTableProperties> {
|
|
142
|
-
let descriptor: Option<prost_types::DescriptorProto> = if let Some(ref desc_str) = self.descriptor_proto {
|
|
143
|
-
let bytes = base64_decode(desc_str)
|
|
144
|
-
.map_err(|e| Error::from_reason(format!("Failed to decode descriptor: {}", e)))?;
|
|
145
|
-
|
|
146
|
-
let descriptor_proto: prost_types::DescriptorProto = prost::Message::decode(&bytes[..])
|
|
147
|
-
.map_err(|e| Error::from_reason(format!("Failed to parse descriptor proto: {}", e)))?;
|
|
148
|
-
|
|
149
|
-
Some(descriptor_proto)
|
|
150
|
-
} else {
|
|
151
|
-
None
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
Ok(RustTableProperties {
|
|
155
|
-
table_name: self.table_name.clone(),
|
|
156
|
-
descriptor_proto: descriptor,
|
|
157
|
-
})
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
111
|
/// Custom error type for Zerobus operations.
|
|
162
112
|
///
|
|
163
113
|
/// This error type includes information about whether the error is retryable,
|
|
@@ -183,6 +133,19 @@ impl ZerobusError {
|
|
|
183
133
|
}
|
|
184
134
|
}
|
|
185
135
|
|
|
136
|
+
/// Convert a JS `BigInt` to `i64`, erroring if it can't be represented losslessly.
|
|
137
|
+
/// Used by `waitForOffset` to avoid the precision loss of the old
|
|
138
|
+
/// `Number(bigint)` round-trip past 2^53.
|
|
139
|
+
fn bigint_to_i64(value: BigInt) -> Result<i64> {
|
|
140
|
+
let (n, lossless) = value.get_i64();
|
|
141
|
+
if !lossless {
|
|
142
|
+
return Err(Error::from_reason(
|
|
143
|
+
"offsetId exceeds i64 range; cannot be represented without loss",
|
|
144
|
+
));
|
|
145
|
+
}
|
|
146
|
+
Ok(n)
|
|
147
|
+
}
|
|
148
|
+
|
|
186
149
|
/// Helper function to convert a JavaScript value to a RustRecordPayload.
|
|
187
150
|
///
|
|
188
151
|
/// Supports:
|
|
@@ -209,17 +172,19 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
|
|
|
209
172
|
if constructor_obj.has_named_property("encode")? {
|
|
210
173
|
let encode_fn: JsFunction = constructor_obj.get_named_property("encode")?;
|
|
211
174
|
let obj_as_unknown = obj.into_unknown();
|
|
212
|
-
let encode_result: JsUnknown =
|
|
175
|
+
let encode_result: JsUnknown =
|
|
176
|
+
encode_fn.call::<JsUnknown>(Some(&constructor_obj), &[obj_as_unknown])?;
|
|
213
177
|
let encode_obj = JsObject::from_unknown(encode_result)?;
|
|
214
178
|
|
|
215
179
|
if encode_obj.has_named_property("finish")? {
|
|
216
180
|
let finish_fn: JsFunction = encode_obj.get_named_property("finish")?;
|
|
217
|
-
let buffer_result: JsUnknown =
|
|
181
|
+
let buffer_result: JsUnknown =
|
|
182
|
+
finish_fn.call::<JsUnknown>(Some(&encode_obj), &[])?;
|
|
218
183
|
let buffer: Buffer = Buffer::from_unknown(buffer_result)?;
|
|
219
184
|
Ok(RustRecordPayload::Proto(buffer.to_vec()))
|
|
220
185
|
} else {
|
|
221
186
|
Err(Error::from_reason(
|
|
222
|
-
"Protobuf message .encode() must return an object with .finish() method"
|
|
187
|
+
"Protobuf message .encode() must return an object with .finish() method",
|
|
223
188
|
))
|
|
224
189
|
}
|
|
225
190
|
} else {
|
|
@@ -227,7 +192,8 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
|
|
|
227
192
|
let json_obj: JsObject = global.get_named_property("JSON")?;
|
|
228
193
|
let stringify: JsFunction = json_obj.get_named_property("stringify")?;
|
|
229
194
|
let obj_as_unknown = obj.into_unknown();
|
|
230
|
-
let str_result: JsUnknown =
|
|
195
|
+
let str_result: JsUnknown =
|
|
196
|
+
stringify.call::<JsUnknown>(Some(&json_obj), &[obj_as_unknown])?;
|
|
231
197
|
let js_string = JsString::from_unknown(str_result)?;
|
|
232
198
|
let json_string = js_string.into_utf8()?.as_str()?.to_string();
|
|
233
199
|
|
|
@@ -240,11 +206,9 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
|
|
|
240
206
|
let json_string = js_string.into_utf8()?.as_str()?.to_string();
|
|
241
207
|
Ok(RustRecordPayload::Json(json_string))
|
|
242
208
|
}
|
|
243
|
-
_ =>
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
))
|
|
247
|
-
}
|
|
209
|
+
_ => Err(Error::from_reason(
|
|
210
|
+
"Payload must be a Buffer, string, protobuf message object, or plain JavaScript object",
|
|
211
|
+
)),
|
|
248
212
|
}
|
|
249
213
|
}
|
|
250
214
|
|
|
@@ -257,8 +221,8 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
|
|
|
257
221
|
///
|
|
258
222
|
/// ```typescript
|
|
259
223
|
/// const stream = await sdk.createStream(tableProps, clientId, clientSecret, options);
|
|
260
|
-
/// const
|
|
261
|
-
///
|
|
224
|
+
/// const offset = await stream.ingestRecordOffset(Buffer.from([1, 2, 3]));
|
|
225
|
+
/// await stream.flush();
|
|
262
226
|
/// await stream.close();
|
|
263
227
|
/// ```
|
|
264
228
|
#[napi]
|
|
@@ -309,35 +273,37 @@ impl ZerobusStream {
|
|
|
309
273
|
#[allow(deprecated)]
|
|
310
274
|
pub fn ingest_record(&self, env: Env, payload: Unknown) -> Result<JsObject> {
|
|
311
275
|
let record_payload = convert_js_to_record_payload(&env, payload)?;
|
|
312
|
-
|
|
313
|
-
let ack_future = {
|
|
314
|
-
let handle = tokio::runtime::Handle::current();
|
|
315
|
-
let stream = self.inner.clone();
|
|
316
|
-
|
|
317
|
-
handle.block_on(async move {
|
|
318
|
-
let mut guard = stream.lock().await;
|
|
319
|
-
let stream_ref = guard
|
|
320
|
-
.as_mut()
|
|
321
|
-
.ok_or_else(|| Error::from_reason("Stream has been closed"))?;
|
|
322
|
-
|
|
323
|
-
stream_ref
|
|
324
|
-
.ingest_record(record_payload)
|
|
325
|
-
.await
|
|
326
|
-
.map_err(|e| Error::from_reason(format!("Failed to ingest record: {}", e)))
|
|
327
|
-
})?
|
|
328
|
-
};
|
|
276
|
+
let stream = self.inner.clone();
|
|
329
277
|
|
|
330
278
|
env.execute_tokio_future(
|
|
331
279
|
async move {
|
|
332
|
-
|
|
333
|
-
.await
|
|
334
|
-
|
|
280
|
+
let offset = {
|
|
281
|
+
let mut guard = stream.lock().await;
|
|
282
|
+
let stream_ref = guard
|
|
283
|
+
.as_mut()
|
|
284
|
+
.ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
|
|
285
|
+
stream_ref
|
|
286
|
+
.ingest_record_offset(record_payload)
|
|
287
|
+
.await
|
|
288
|
+
.map_err(|e| {
|
|
289
|
+
napi::Error::from_reason(format!("Failed to ingest record: {}", e))
|
|
290
|
+
})?
|
|
291
|
+
};
|
|
292
|
+
{
|
|
293
|
+
let guard = stream.lock().await;
|
|
294
|
+
let stream_ref = guard
|
|
295
|
+
.as_ref()
|
|
296
|
+
.ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
|
|
297
|
+
stream_ref.wait_for_offset(offset).await.map_err(|e| {
|
|
298
|
+
napi::Error::from_reason(format!("Acknowledgment failed: {}", e))
|
|
299
|
+
})?;
|
|
300
|
+
}
|
|
301
|
+
Ok(offset)
|
|
335
302
|
},
|
|
336
|
-
|env,
|
|
337
|
-
let result_str = result.to_string();
|
|
303
|
+
|env, offset_id| {
|
|
338
304
|
let global: JsGlobal = env.get_global()?;
|
|
339
305
|
let bigint_ctor: JsFunction = global.get_named_property("BigInt")?;
|
|
340
|
-
let js_str = env.create_string(&
|
|
306
|
+
let js_str = env.create_string(&offset_id.to_string())?;
|
|
341
307
|
bigint_ctor.call(None, &[js_str.into_unknown()])
|
|
342
308
|
},
|
|
343
309
|
)
|
|
@@ -378,50 +344,49 @@ impl ZerobusStream {
|
|
|
378
344
|
#[napi(ts_return_type = "Promise<bigint | null>")]
|
|
379
345
|
#[allow(deprecated)]
|
|
380
346
|
pub fn ingest_records(&self, env: Env, records: Vec<Unknown>) -> Result<JsObject> {
|
|
347
|
+
// Rust SDK 2.0 removed the blocking `ingest_records`. v1 semantics
|
|
348
|
+
// (Promise resolves after server ack; `null` for empty batches) are
|
|
349
|
+
// preserved via `ingest_records_offset` + `wait_for_offset`.
|
|
381
350
|
let record_payloads: Result<Vec<RustRecordPayload>> = records
|
|
382
351
|
.into_iter()
|
|
383
352
|
.map(|payload| convert_js_to_record_payload(&env, payload))
|
|
384
353
|
.collect();
|
|
385
|
-
|
|
386
354
|
let record_payloads = record_payloads?;
|
|
387
|
-
|
|
388
|
-
let ack_future_option = {
|
|
389
|
-
let handle = tokio::runtime::Handle::current();
|
|
390
|
-
let stream = self.inner.clone();
|
|
391
|
-
|
|
392
|
-
handle.block_on(async move {
|
|
393
|
-
let mut guard = stream.lock().await;
|
|
394
|
-
let stream_ref = guard
|
|
395
|
-
.as_mut()
|
|
396
|
-
.ok_or_else(|| Error::from_reason("Stream has been closed"))?;
|
|
397
|
-
|
|
398
|
-
// Send batch to SDK
|
|
399
|
-
stream_ref
|
|
400
|
-
.ingest_records(record_payloads)
|
|
401
|
-
.await
|
|
402
|
-
.map_err(|e| Error::from_reason(format!("Failed to ingest batch: {}", e)))
|
|
403
|
-
})?
|
|
404
|
-
};
|
|
355
|
+
let stream = self.inner.clone();
|
|
405
356
|
|
|
406
357
|
env.execute_tokio_future(
|
|
407
358
|
async move {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
359
|
+
let offset_opt = {
|
|
360
|
+
let mut guard = stream.lock().await;
|
|
361
|
+
let stream_ref = guard
|
|
362
|
+
.as_mut()
|
|
363
|
+
.ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
|
|
364
|
+
stream_ref
|
|
365
|
+
.ingest_records_offset(record_payloads)
|
|
366
|
+
.await
|
|
367
|
+
.map_err(|e| {
|
|
368
|
+
napi::Error::from_reason(format!("Failed to ingest batch: {}", e))
|
|
369
|
+
})?
|
|
370
|
+
};
|
|
371
|
+
if let Some(offset) = offset_opt {
|
|
372
|
+
let guard = stream.lock().await;
|
|
373
|
+
let stream_ref = guard
|
|
374
|
+
.as_ref()
|
|
375
|
+
.ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
|
|
376
|
+
stream_ref.wait_for_offset(offset).await.map_err(|e| {
|
|
377
|
+
napi::Error::from_reason(format!("Batch acknowledgment failed: {}", e))
|
|
378
|
+
})?;
|
|
414
379
|
}
|
|
380
|
+
Ok(offset_opt)
|
|
415
381
|
},
|
|
416
382
|
|env, result| match result {
|
|
417
383
|
Some(offset_id) => {
|
|
418
|
-
let offset_str = offset_id.to_string();
|
|
419
384
|
let global: JsGlobal = env.get_global()?;
|
|
420
385
|
let bigint_ctor: JsFunction = global.get_named_property("BigInt")?;
|
|
421
|
-
let js_str = env.create_string(&
|
|
386
|
+
let js_str = env.create_string(&offset_id.to_string())?;
|
|
422
387
|
let bigint = bigint_ctor.call(None, &[js_str.into_unknown()])?;
|
|
423
388
|
Ok(bigint.into_unknown())
|
|
424
|
-
}
|
|
389
|
+
}
|
|
425
390
|
None => env.get_null().map(|v| v.into_unknown()),
|
|
426
391
|
},
|
|
427
392
|
)
|
|
@@ -436,6 +401,13 @@ impl ZerobusStream {
|
|
|
436
401
|
/// This is the recommended API for high-throughput scenarios where you want to
|
|
437
402
|
/// decouple record ingestion from acknowledgment tracking.
|
|
438
403
|
///
|
|
404
|
+
/// **Acknowledgments:** the idiomatic flow is to ingest in a loop and then `flush()`
|
|
405
|
+
/// once to confirm everything queued so far. The returned offset, together with
|
|
406
|
+
/// `waitForOffset()`, lets you confirm a specific record when you need it (acks are
|
|
407
|
+
/// ordered, so the last offset confirms the whole run) — prefer `flush()` for bulk.
|
|
408
|
+
/// Avoid calling `waitForOffset()` after every record in a tight loop, since that
|
|
409
|
+
/// limits throughput to one record per round-trip.
|
|
410
|
+
///
|
|
439
411
|
/// # Arguments
|
|
440
412
|
///
|
|
441
413
|
/// * `payload` - The record data (Buffer, string, protobuf message, or plain object)
|
|
@@ -448,11 +420,14 @@ impl ZerobusStream {
|
|
|
448
420
|
/// # Example
|
|
449
421
|
///
|
|
450
422
|
/// ```typescript
|
|
451
|
-
/// //
|
|
452
|
-
///
|
|
453
|
-
/// const
|
|
454
|
-
/// //
|
|
455
|
-
///
|
|
423
|
+
/// // High-throughput pattern: ingest in a loop, wait once at the end.
|
|
424
|
+
/// let lastOffset: bigint | null = null;
|
|
425
|
+
/// for (const record of records) {
|
|
426
|
+
/// lastOffset = await stream.ingestRecordOffset(record); // resolves on queue, no round-trip
|
|
427
|
+
/// }
|
|
428
|
+
/// // The ack watermark is monotonic: waiting on the last offset confirms all prior records.
|
|
429
|
+
/// if (lastOffset !== null) await stream.waitForOffset(lastOffset);
|
|
430
|
+
/// // Or simply: await stream.flush();
|
|
456
431
|
/// ```
|
|
457
432
|
#[napi(ts_return_type = "Promise<bigint>")]
|
|
458
433
|
pub fn ingest_record_offset(&self, env: Env, payload: Unknown) -> Result<JsObject> {
|
|
@@ -470,7 +445,9 @@ impl ZerobusStream {
|
|
|
470
445
|
stream_ref
|
|
471
446
|
.ingest_record_offset(record_payload)
|
|
472
447
|
.await
|
|
473
|
-
.map_err(|e|
|
|
448
|
+
.map_err(|e| {
|
|
449
|
+
napi::Error::from_reason(format!("Failed to ingest record: {}", e))
|
|
450
|
+
})
|
|
474
451
|
},
|
|
475
452
|
|env, offset_id| {
|
|
476
453
|
let offset_str = offset_id.to_string();
|
|
@@ -488,6 +465,12 @@ impl ZerobusStream {
|
|
|
488
465
|
/// the batch is queued, without waiting for server acknowledgment. Use
|
|
489
466
|
/// `waitForOffset()` to wait for acknowledgment when needed.
|
|
490
467
|
///
|
|
468
|
+
/// **Acknowledgments:** the idiomatic flow is to ingest your batches in a loop and
|
|
469
|
+
/// then `flush()` once to confirm. The returned offset, together with `waitForOffset()`,
|
|
470
|
+
/// confirms a specific batch when you need it (acks are ordered, so the last offset
|
|
471
|
+
/// confirms the whole run) — prefer `flush()` for bulk. Avoid calling `waitForOffset()`
|
|
472
|
+
/// after every batch in a tight loop, since that limits throughput to one round-trip per batch.
|
|
473
|
+
///
|
|
491
474
|
/// # Arguments
|
|
492
475
|
///
|
|
493
476
|
/// * `records` - Array of record data
|
|
@@ -500,11 +483,14 @@ impl ZerobusStream {
|
|
|
500
483
|
/// # Example
|
|
501
484
|
///
|
|
502
485
|
/// ```typescript
|
|
503
|
-
/// //
|
|
504
|
-
///
|
|
505
|
-
///
|
|
506
|
-
/// await stream.
|
|
486
|
+
/// // Ingest many batches without waiting, then flush once.
|
|
487
|
+
/// let lastOffset = null;
|
|
488
|
+
/// for (const batch of batches) {
|
|
489
|
+
/// const offset = await stream.ingestRecordsOffset(batch); // resolves on queue
|
|
490
|
+
/// if (offset !== null) lastOffset = offset;
|
|
507
491
|
/// }
|
|
492
|
+
/// if (lastOffset !== null) await stream.waitForOffset(lastOffset);
|
|
493
|
+
/// // Or simply: await stream.flush();
|
|
508
494
|
/// ```
|
|
509
495
|
#[napi(ts_return_type = "Promise<bigint | null>")]
|
|
510
496
|
pub fn ingest_records_offset(&self, env: Env, records: Vec<Unknown>) -> Result<JsObject> {
|
|
@@ -537,7 +523,7 @@ impl ZerobusStream {
|
|
|
537
523
|
let js_str = env.create_string(&offset_str)?;
|
|
538
524
|
let bigint = bigint_ctor.call(None, &[js_str.into_unknown()])?;
|
|
539
525
|
Ok(bigint.into_unknown())
|
|
540
|
-
}
|
|
526
|
+
}
|
|
541
527
|
None => env.get_null().map(|v| v.into_unknown()),
|
|
542
528
|
},
|
|
543
529
|
)
|
|
@@ -545,9 +531,12 @@ impl ZerobusStream {
|
|
|
545
531
|
|
|
546
532
|
/// Waits for a specific offset to be acknowledged by the server.
|
|
547
533
|
///
|
|
548
|
-
/// Use this method with `ingestRecordOffset()` and `ingestRecordsOffset()` to
|
|
549
|
-
///
|
|
550
|
-
///
|
|
534
|
+
/// Use this method with `ingestRecordOffset()` and `ingestRecordsOffset()` to confirm
|
|
535
|
+
/// a specific record before continuing. Acks are ordered, so waiting on the LAST offset
|
|
536
|
+
/// confirms every prior record too — you never need to wait on intermediate offsets.
|
|
537
|
+
/// For confirming a bulk run, `flush()` is usually simpler; reach for `waitForOffset()`
|
|
538
|
+
/// when one particular record must be confirmed. Avoid calling it after every record in
|
|
539
|
+
/// a tight loop, since that limits throughput to one record per round-trip.
|
|
551
540
|
///
|
|
552
541
|
/// # Arguments
|
|
553
542
|
///
|
|
@@ -561,19 +550,16 @@ impl ZerobusStream {
|
|
|
561
550
|
/// # Example
|
|
562
551
|
///
|
|
563
552
|
/// ```typescript
|
|
564
|
-
///
|
|
553
|
+
/// let lastOffset: bigint | null = null;
|
|
565
554
|
/// for (const record of records) {
|
|
566
|
-
///
|
|
555
|
+
/// lastOffset = await stream.ingestRecordOffset(record); // no per-record wait
|
|
567
556
|
/// }
|
|
568
|
-
/// // Wait for the last offset (implies all previous are also acknowledged)
|
|
569
|
-
/// await stream.waitForOffset(
|
|
557
|
+
/// // Wait for the last offset only (implies all previous are also acknowledged).
|
|
558
|
+
/// if (lastOffset !== null) await stream.waitForOffset(lastOffset);
|
|
570
559
|
/// ```
|
|
571
|
-
#[napi(
|
|
572
|
-
pub fn wait_for_offset(&self, env: Env, offset_id:
|
|
573
|
-
let
|
|
574
|
-
let number_ctor: JsFunction = global.get_named_property("Number")?;
|
|
575
|
-
let num_result: JsUnknown = number_ctor.call(None, &[offset_id])?;
|
|
576
|
-
let offset: i64 = num_result.coerce_to_number()?.get_int64()?;
|
|
560
|
+
#[napi(ts_return_type = "Promise<void>")]
|
|
561
|
+
pub fn wait_for_offset(&self, env: Env, offset_id: BigInt) -> Result<JsObject> {
|
|
562
|
+
let offset = bigint_to_i64(offset_id)?;
|
|
577
563
|
|
|
578
564
|
let stream = self.inner.clone();
|
|
579
565
|
|
|
@@ -584,10 +570,9 @@ impl ZerobusStream {
|
|
|
584
570
|
.as_ref()
|
|
585
571
|
.ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
|
|
586
572
|
|
|
587
|
-
stream_ref
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
.map_err(|e| napi::Error::from_reason(format!("Failed to wait for offset: {}", e)))
|
|
573
|
+
stream_ref.wait_for_offset(offset).await.map_err(|e| {
|
|
574
|
+
napi::Error::from_reason(format!("Failed to wait for offset: {}", e))
|
|
575
|
+
})
|
|
591
576
|
},
|
|
592
577
|
|_env, _| Ok(()),
|
|
593
578
|
)
|
|
@@ -598,6 +583,11 @@ impl ZerobusStream {
|
|
|
598
583
|
/// This method ensures all previously ingested records have been sent to the server
|
|
599
584
|
/// and acknowledged. It's useful for checkpointing or ensuring data durability.
|
|
600
585
|
///
|
|
586
|
+
/// This is the idiomatic way to confirm records ingested via `ingestRecordOffset()` /
|
|
587
|
+
/// `ingestRecordsOffset()`: ingest in a loop, then `flush()` once (for a bounded batch,
|
|
588
|
+
/// or periodically for a long-running stream). It resolves once everything queued so
|
|
589
|
+
/// far is acknowledged.
|
|
590
|
+
///
|
|
601
591
|
/// # Errors
|
|
602
592
|
///
|
|
603
593
|
/// - Timeout errors if flush takes longer than configured timeout
|
|
@@ -684,15 +674,12 @@ impl ZerobusStream {
|
|
|
684
674
|
///
|
|
685
675
|
/// ```typescript
|
|
686
676
|
/// try {
|
|
687
|
-
/// await stream.
|
|
688
|
-
/// await stream.
|
|
677
|
+
/// await stream.ingestRecordsOffset(batch1);
|
|
678
|
+
/// await stream.ingestRecordsOffset(batch2);
|
|
679
|
+
/// await stream.flush();
|
|
689
680
|
/// } catch (error) {
|
|
690
681
|
/// const unackedBatches = await stream.getUnackedBatches();
|
|
691
|
-
///
|
|
692
|
-
/// // Re-ingest with new stream
|
|
693
|
-
/// for (const batch of unackedBatches) {
|
|
694
|
-
/// await newStream.ingestRecords(batch);
|
|
695
|
-
/// }
|
|
682
|
+
/// console.log(`Batches available for recovery: ${unackedBatches.length}`);
|
|
696
683
|
/// }
|
|
697
684
|
/// ```
|
|
698
685
|
#[napi]
|
|
@@ -725,10 +712,10 @@ impl ZerobusStream {
|
|
|
725
712
|
/// JavaScript headers provider callback wrapper.
|
|
726
713
|
///
|
|
727
714
|
/// Allows TypeScript code to provide custom authentication headers
|
|
728
|
-
/// by implementing a
|
|
715
|
+
/// by implementing a getHeadersCallback() function.
|
|
729
716
|
#[napi(object)]
|
|
730
717
|
pub struct JsHeadersProvider {
|
|
731
|
-
/// JavaScript function: () =>
|
|
718
|
+
/// JavaScript function: () => Array<[string, string]>
|
|
732
719
|
pub get_headers_callback: JsFunction,
|
|
733
720
|
}
|
|
734
721
|
|
|
@@ -750,20 +737,15 @@ impl StaticHeadersProvider {
|
|
|
750
737
|
|
|
751
738
|
if !map.contains_key("authorization") {
|
|
752
739
|
return Err(RustZerobusError::InvalidArgument(
|
|
753
|
-
"HeadersProvider must include 'authorization' header with Bearer token".to_string()
|
|
740
|
+
"HeadersProvider must include 'authorization' header with Bearer token".to_string(),
|
|
754
741
|
));
|
|
755
742
|
}
|
|
756
743
|
if !map.contains_key("x-databricks-zerobus-table-name") {
|
|
757
744
|
return Err(RustZerobusError::InvalidArgument(
|
|
758
|
-
"HeadersProvider must include 'x-databricks-zerobus-table-name' header".to_string()
|
|
745
|
+
"HeadersProvider must include 'x-databricks-zerobus-table-name' header".to_string(),
|
|
759
746
|
));
|
|
760
747
|
}
|
|
761
748
|
|
|
762
|
-
// Add TS user agent if not provided
|
|
763
|
-
if !map.contains_key("user-agent") {
|
|
764
|
-
map.insert("user-agent", TS_SDK_USER_AGENT.to_string());
|
|
765
|
-
}
|
|
766
|
-
|
|
767
749
|
Ok(Self { headers: map })
|
|
768
750
|
}
|
|
769
751
|
}
|
|
@@ -776,13 +758,18 @@ impl RustHeadersProvider for StaticHeadersProvider {
|
|
|
776
758
|
}
|
|
777
759
|
|
|
778
760
|
/// Helper to create a threadsafe function from JavaScript callback
|
|
779
|
-
fn create_headers_tsfn(
|
|
761
|
+
fn create_headers_tsfn(
|
|
762
|
+
js_func: JsFunction,
|
|
763
|
+
) -> Result<ThreadsafeFunction<(), ErrorStrategy::Fatal>> {
|
|
780
764
|
js_func.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))
|
|
781
765
|
}
|
|
782
766
|
|
|
783
767
|
/// Helper to call headers callback and get result
|
|
784
|
-
async fn call_headers_tsfn(
|
|
785
|
-
|
|
768
|
+
async fn call_headers_tsfn(
|
|
769
|
+
tsfn: ThreadsafeFunction<(), ErrorStrategy::Fatal>,
|
|
770
|
+
) -> Result<Vec<(String, String)>> {
|
|
771
|
+
let raw_headers: Vec<Vec<String>> = tsfn
|
|
772
|
+
.call_async(())
|
|
786
773
|
.await
|
|
787
774
|
.map_err(|e| Error::from_reason(format!("Failed to call headers callback: {}", e)))?;
|
|
788
775
|
|
|
@@ -843,11 +830,17 @@ impl RustHeadersProvider for TsOAuthHeadersProvider {
|
|
|
843
830
|
let mut headers = HashMap::new();
|
|
844
831
|
headers.insert("authorization", format!("Bearer {}", token));
|
|
845
832
|
headers.insert("x-databricks-zerobus-table-name", self.table_name.clone());
|
|
846
|
-
headers.insert("user-agent", TS_SDK_USER_AGENT.to_string());
|
|
847
833
|
Ok(headers)
|
|
848
834
|
}
|
|
849
835
|
}
|
|
850
836
|
|
|
837
|
+
#[napi(object)]
|
|
838
|
+
#[derive(Default)]
|
|
839
|
+
pub struct ZerobusSdkOptions {
|
|
840
|
+
/// Identifier appended to the `user-agent` header
|
|
841
|
+
pub application_name: Option<String>,
|
|
842
|
+
}
|
|
843
|
+
|
|
851
844
|
/// The main SDK for interacting with the Databricks Zerobus service.
|
|
852
845
|
///
|
|
853
846
|
/// This is the entry point for creating ingestion streams to Delta tables.
|
|
@@ -857,7 +850,8 @@ impl RustHeadersProvider for TsOAuthHeadersProvider {
|
|
|
857
850
|
/// ```typescript
|
|
858
851
|
/// const sdk = new ZerobusSdk(
|
|
859
852
|
/// "https://workspace-id.zerobus.region.cloud.databricks.com",
|
|
860
|
-
/// "https://workspace.cloud.databricks.com"
|
|
853
|
+
/// "https://workspace.cloud.databricks.com",
|
|
854
|
+
/// { applicationName: "my-app/1.0" }
|
|
861
855
|
/// );
|
|
862
856
|
///
|
|
863
857
|
/// const stream = await sdk.createStream(
|
|
@@ -885,25 +879,41 @@ impl ZerobusSdk {
|
|
|
885
879
|
/// (e.g., "https://workspace-id.zerobus.region.cloud.databricks.com")
|
|
886
880
|
/// * `unity_catalog_url` - The Unity Catalog endpoint URL
|
|
887
881
|
/// (e.g., "https://workspace.cloud.databricks.com")
|
|
882
|
+
/// * `options` - Optional SDK configuration (see `ZerobusSdkOptions`),
|
|
883
|
+
/// including `applicationName` for server-side attribution.
|
|
888
884
|
///
|
|
889
885
|
/// # Errors
|
|
890
886
|
///
|
|
891
887
|
/// - Invalid endpoint URLs
|
|
892
888
|
/// - Failed to extract workspace ID from the endpoint
|
|
893
889
|
#[napi(constructor)]
|
|
894
|
-
pub fn new(
|
|
890
|
+
pub fn new(
|
|
891
|
+
zerobus_endpoint: String,
|
|
892
|
+
unity_catalog_url: String,
|
|
893
|
+
options: Option<ZerobusSdkOptions>,
|
|
894
|
+
) -> Result<Self> {
|
|
895
895
|
let workspace_id = zerobus_endpoint
|
|
896
896
|
.strip_prefix("https://")
|
|
897
897
|
.or_else(|| zerobus_endpoint.strip_prefix("http://"))
|
|
898
898
|
.and_then(|s| s.split('.').next())
|
|
899
899
|
.map(|s| s.to_string())
|
|
900
900
|
.ok_or_else(|| {
|
|
901
|
-
Error::from_reason(
|
|
901
|
+
Error::from_reason(
|
|
902
|
+
"Failed to extract workspace_id from zerobus_endpoint".to_string(),
|
|
903
|
+
)
|
|
902
904
|
})?;
|
|
903
905
|
|
|
904
|
-
let
|
|
906
|
+
let options = options.unwrap_or_default();
|
|
907
|
+
|
|
908
|
+
let builder = RustZerobusSdk::builder()
|
|
905
909
|
.endpoint(&zerobus_endpoint)
|
|
906
910
|
.unity_catalog_url(&unity_catalog_url)
|
|
911
|
+
.sdk_identifier(TS_SDK_USER_AGENT);
|
|
912
|
+
let builder = match options.application_name {
|
|
913
|
+
Some(name) => builder.application_name(name),
|
|
914
|
+
None => builder,
|
|
915
|
+
};
|
|
916
|
+
let inner = builder
|
|
907
917
|
.build()
|
|
908
918
|
.map_err(|e| Error::from_reason(format!("Failed to create SDK: {}", e)))?;
|
|
909
919
|
|
|
@@ -958,7 +968,7 @@ impl ZerobusSdk {
|
|
|
958
968
|
/// "", // ignored
|
|
959
969
|
/// undefined,
|
|
960
970
|
/// {
|
|
961
|
-
/// getHeadersCallback:
|
|
971
|
+
/// getHeadersCallback: () => [
|
|
962
972
|
/// ["authorization", `Bearer ${myToken}`],
|
|
963
973
|
/// ["x-databricks-zerobus-table-name", tableName]
|
|
964
974
|
/// ]
|
|
@@ -975,13 +985,45 @@ impl ZerobusSdk {
|
|
|
975
985
|
options: Option<StreamConfigurationOptions>,
|
|
976
986
|
headers_provider: Option<JsHeadersProvider>,
|
|
977
987
|
) -> Result<JsObject> {
|
|
978
|
-
|
|
979
|
-
|
|
988
|
+
// Decode the optional protobuf descriptor up-front so we can hand it
|
|
989
|
+
// to the builder's `.compiled_proto()` setter; the builder constructs
|
|
990
|
+
// the (now-private) `TableProperties` itself.
|
|
991
|
+
let descriptor_proto: Option<prost_types::DescriptorProto> = if let Some(ref desc_str) =
|
|
992
|
+
table_properties.descriptor_proto
|
|
993
|
+
{
|
|
994
|
+
let bytes = base64_decode(desc_str)
|
|
995
|
+
.map_err(|e| Error::from_reason(format!("Failed to decode descriptor: {}", e)))?;
|
|
996
|
+
let dp: prost_types::DescriptorProto =
|
|
997
|
+
prost::Message::decode(&bytes[..]).map_err(|e| {
|
|
998
|
+
Error::from_reason(format!("Failed to parse descriptor proto: {}", e))
|
|
999
|
+
})?;
|
|
1000
|
+
Some(dp)
|
|
1001
|
+
} else {
|
|
1002
|
+
None
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
let opts = options.unwrap_or(StreamConfigurationOptions {
|
|
1006
|
+
max_inflight_requests: None,
|
|
1007
|
+
recovery: None,
|
|
1008
|
+
recovery_timeout_ms: None,
|
|
1009
|
+
recovery_backoff_ms: None,
|
|
1010
|
+
recovery_retries: None,
|
|
1011
|
+
flush_timeout_ms: None,
|
|
1012
|
+
server_lack_of_ack_timeout_ms: None,
|
|
1013
|
+
record_type: None,
|
|
1014
|
+
stream_paused_max_wait_time_ms: None,
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
let record_type = match opts.record_type {
|
|
1018
|
+
Some(0) => RustRecordType::Json,
|
|
1019
|
+
Some(1) => RustRecordType::Proto,
|
|
1020
|
+
_ => RustRecordType::Proto,
|
|
1021
|
+
};
|
|
980
1022
|
|
|
981
1023
|
let headers_tsfn = match headers_provider {
|
|
982
|
-
Some(JsHeadersProvider {
|
|
983
|
-
|
|
984
|
-
}
|
|
1024
|
+
Some(JsHeadersProvider {
|
|
1025
|
+
get_headers_callback,
|
|
1026
|
+
}) => Some(create_headers_tsfn(get_headers_callback)?),
|
|
985
1027
|
None => None,
|
|
986
1028
|
};
|
|
987
1029
|
|
|
@@ -992,34 +1034,70 @@ impl ZerobusSdk {
|
|
|
992
1034
|
|
|
993
1035
|
env.execute_tokio_future(
|
|
994
1036
|
async move {
|
|
995
|
-
let headers_provider_arc: Arc<dyn RustHeadersProvider> = if let Some(tsfn) =
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1037
|
+
let headers_provider_arc: Arc<dyn RustHeadersProvider> = if let Some(tsfn) =
|
|
1038
|
+
headers_tsfn
|
|
1039
|
+
{
|
|
1040
|
+
let headers = call_headers_tsfn(tsfn).await.map_err(|e| {
|
|
1041
|
+
napi::Error::from_reason(format!("Headers callback failed: {}", e))
|
|
1042
|
+
})?;
|
|
1000
1043
|
let static_provider = StaticHeadersProvider::new(headers)
|
|
1001
1044
|
.map_err(|e| napi::Error::from_reason(format!("Invalid headers: {}", e)))?;
|
|
1002
|
-
|
|
1003
1045
|
Arc::new(static_provider)
|
|
1004
1046
|
} else {
|
|
1005
|
-
// Default OAuth with TS user agent
|
|
1006
1047
|
Arc::new(TsOAuthHeadersProvider::new(
|
|
1007
1048
|
client_id,
|
|
1008
1049
|
client_secret,
|
|
1009
|
-
table_name,
|
|
1050
|
+
table_name.clone(),
|
|
1010
1051
|
workspace_id,
|
|
1011
1052
|
unity_catalog_url,
|
|
1012
1053
|
))
|
|
1013
1054
|
};
|
|
1014
1055
|
|
|
1015
|
-
let
|
|
1016
|
-
.
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
.
|
|
1022
|
-
|
|
1056
|
+
let mut builder = sdk
|
|
1057
|
+
.stream_builder()
|
|
1058
|
+
.table(table_name)
|
|
1059
|
+
.headers_provider(headers_provider_arc);
|
|
1060
|
+
|
|
1061
|
+
if let Some(v) = opts.max_inflight_requests {
|
|
1062
|
+
builder = builder.max_inflight_requests(v as usize);
|
|
1063
|
+
}
|
|
1064
|
+
if let Some(v) = opts.recovery {
|
|
1065
|
+
builder = builder.recovery(v);
|
|
1066
|
+
}
|
|
1067
|
+
if let Some(v) = opts.recovery_timeout_ms {
|
|
1068
|
+
builder = builder.recovery_timeout_ms(v as u64);
|
|
1069
|
+
}
|
|
1070
|
+
if let Some(v) = opts.recovery_backoff_ms {
|
|
1071
|
+
builder = builder.recovery_backoff_ms(v as u64);
|
|
1072
|
+
}
|
|
1073
|
+
if let Some(v) = opts.recovery_retries {
|
|
1074
|
+
builder = builder.recovery_retries(v);
|
|
1075
|
+
}
|
|
1076
|
+
if let Some(v) = opts.flush_timeout_ms {
|
|
1077
|
+
builder = builder.flush_timeout_ms(v as u64);
|
|
1078
|
+
}
|
|
1079
|
+
if let Some(v) = opts.server_lack_of_ack_timeout_ms {
|
|
1080
|
+
builder = builder.server_lack_of_ack_timeout_ms(v as u64);
|
|
1081
|
+
}
|
|
1082
|
+
if let Some(v) = opts.stream_paused_max_wait_time_ms {
|
|
1083
|
+
builder = builder.stream_paused_max_wait_time_ms(Some(v as u64));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
let builder = match record_type {
|
|
1087
|
+
RustRecordType::Json => builder.json(),
|
|
1088
|
+
RustRecordType::Proto | RustRecordType::Unspecified => {
|
|
1089
|
+
let desc = descriptor_proto.ok_or_else(|| {
|
|
1090
|
+
napi::Error::from_reason(
|
|
1091
|
+
"Proto record type requires descriptor_proto on TableProperties",
|
|
1092
|
+
)
|
|
1093
|
+
})?;
|
|
1094
|
+
builder.compiled_proto(desc)
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
let stream = builder.build().await.map_err(|e| {
|
|
1099
|
+
napi::Error::from_reason(format!("Failed to create stream: {}", e))
|
|
1100
|
+
})?;
|
|
1023
1101
|
|
|
1024
1102
|
Ok(ZerobusStream {
|
|
1025
1103
|
inner: Arc::new(Mutex::new(Some(stream))),
|
|
@@ -1039,7 +1117,8 @@ impl ZerobusSdk {
|
|
|
1039
1117
|
///
|
|
1040
1118
|
/// # Arguments
|
|
1041
1119
|
///
|
|
1042
|
-
/// * `stream` - The failed
|
|
1120
|
+
/// * `stream` - The terminally failed stream to recreate. The TypeScript wrapper
|
|
1121
|
+
/// must not have been closed because `close()` releases its native handle.
|
|
1043
1122
|
///
|
|
1044
1123
|
/// # Returns
|
|
1045
1124
|
///
|
|
@@ -1055,12 +1134,23 @@ impl ZerobusSdk {
|
|
|
1055
1134
|
///
|
|
1056
1135
|
/// ```typescript
|
|
1057
1136
|
/// try {
|
|
1058
|
-
/// await stream.
|
|
1137
|
+
/// await stream.ingestRecordsOffset(batch);
|
|
1138
|
+
/// await stream.flush();
|
|
1059
1139
|
/// } catch (error) {
|
|
1060
|
-
///
|
|
1061
|
-
///
|
|
1062
|
-
///
|
|
1063
|
-
///
|
|
1140
|
+
/// try {
|
|
1141
|
+
/// const newStream = await sdk.recreateStream(stream);
|
|
1142
|
+
/// try {
|
|
1143
|
+
/// await newStream.flush();
|
|
1144
|
+
/// } finally {
|
|
1145
|
+
/// await newStream.close();
|
|
1146
|
+
/// }
|
|
1147
|
+
/// } finally {
|
|
1148
|
+
/// try {
|
|
1149
|
+
/// await stream.close();
|
|
1150
|
+
/// } catch (closeError) {
|
|
1151
|
+
/// console.error("Failed stream released:", closeError);
|
|
1152
|
+
/// }
|
|
1153
|
+
/// }
|
|
1064
1154
|
/// }
|
|
1065
1155
|
/// ```
|
|
1066
1156
|
#[napi]
|
|
@@ -1091,31 +1181,24 @@ fn base64_decode(input: &str) -> std::result::Result<Vec<u8>, String> {
|
|
|
1091
1181
|
}
|
|
1092
1182
|
|
|
1093
1183
|
// =============================================================================
|
|
1094
|
-
// Arrow Flight Support (
|
|
1184
|
+
// Arrow Flight Support (Beta)
|
|
1095
1185
|
// Enabled with feature flag: cargo build --features arrow-flight
|
|
1096
1186
|
// =============================================================================
|
|
1097
1187
|
|
|
1098
|
-
#[cfg(feature = "arrow-flight")]
|
|
1099
|
-
use databricks_zerobus_ingest_sdk::{
|
|
1100
|
-
ArrowStreamConfigurationOptions as RustArrowStreamOptions,
|
|
1101
|
-
ArrowTableProperties as RustArrowTableProperties,
|
|
1102
|
-
ZerobusArrowStream as RustZerobusArrowStream,
|
|
1103
|
-
ArrowSchema as RustArrowSchema,
|
|
1104
|
-
RecordBatch as RustRecordBatch,
|
|
1105
|
-
Field as RustField,
|
|
1106
|
-
DataType as RustDataType,
|
|
1107
|
-
};
|
|
1108
|
-
#[cfg(feature = "arrow-flight")]
|
|
1109
|
-
use arrow_ipc::reader::StreamReader;
|
|
1110
1188
|
#[cfg(feature = "arrow-flight")]
|
|
1111
1189
|
use arrow_ipc::writer::StreamWriter;
|
|
1112
1190
|
#[cfg(feature = "arrow-flight")]
|
|
1113
|
-
use
|
|
1191
|
+
use bytes::Bytes;
|
|
1192
|
+
#[cfg(feature = "arrow-flight")]
|
|
1193
|
+
use databricks_zerobus_ingest_sdk::{
|
|
1194
|
+
ArrowSchema as RustArrowSchema, DataType as RustDataType, Field as RustField,
|
|
1195
|
+
RecordBatch as RustRecordBatch, ZerobusArrowStream as RustZerobusArrowStream,
|
|
1196
|
+
};
|
|
1114
1197
|
|
|
1115
1198
|
/// IPC compression type for Arrow Flight streams.
|
|
1116
1199
|
///
|
|
1117
|
-
/// **
|
|
1118
|
-
///
|
|
1200
|
+
/// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
|
|
1201
|
+
/// may still change before reaching GA.
|
|
1119
1202
|
#[cfg(feature = "arrow-flight")]
|
|
1120
1203
|
#[napi]
|
|
1121
1204
|
pub enum IpcCompressionType {
|
|
@@ -1127,8 +1210,8 @@ pub enum IpcCompressionType {
|
|
|
1127
1210
|
|
|
1128
1211
|
/// Configuration options for Arrow Flight streams.
|
|
1129
1212
|
///
|
|
1130
|
-
/// **
|
|
1131
|
-
///
|
|
1213
|
+
/// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
|
|
1214
|
+
/// may still change before reaching GA.
|
|
1132
1215
|
#[cfg(feature = "arrow-flight")]
|
|
1133
1216
|
#[napi(object)]
|
|
1134
1217
|
#[derive(Debug, Clone)]
|
|
@@ -1169,34 +1252,23 @@ pub struct ArrowStreamConfigurationOptions {
|
|
|
1169
1252
|
pub ipc_compression: Option<i32>,
|
|
1170
1253
|
}
|
|
1171
1254
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
let ipc_compression = match opts.ipc_compression {
|
|
1178
|
-
Some(0) => Some(arrow_ipc::CompressionType::LZ4_FRAME),
|
|
1179
|
-
Some(1) => Some(arrow_ipc::CompressionType::ZSTD),
|
|
1180
|
-
_ => None,
|
|
1181
|
-
};
|
|
1255
|
+
// Rust SDK 2.0 `ArrowStreamConfigurationOptions` is `#[non_exhaustive]` and
|
|
1256
|
+
// cannot be constructed via struct literal from this crate. Arrow options are
|
|
1257
|
+
// applied via setters on `sdk.stream_builder()` inside `create_arrow_stream`
|
|
1258
|
+
// below.
|
|
1182
1259
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
server_lack_of_ack_timeout_ms: opts.server_lack_of_ack_timeout_ms.map(|v| v as u64).unwrap_or(default.server_lack_of_ack_timeout_ms),
|
|
1190
|
-
flush_timeout_ms: opts.flush_timeout_ms.map(|v| v as u64).unwrap_or(default.flush_timeout_ms),
|
|
1191
|
-
connection_timeout_ms: opts.connection_timeout_ms.map(|v| v as u64).unwrap_or(default.connection_timeout_ms),
|
|
1192
|
-
ipc_compression,
|
|
1193
|
-
}
|
|
1260
|
+
#[cfg(feature = "arrow-flight")]
|
|
1261
|
+
fn map_ipc_compression(value: Option<i32>) -> Option<arrow_ipc::CompressionType> {
|
|
1262
|
+
match value {
|
|
1263
|
+
Some(0) => Some(arrow_ipc::CompressionType::LZ4_FRAME),
|
|
1264
|
+
Some(1) => Some(arrow_ipc::CompressionType::ZSTD),
|
|
1265
|
+
_ => None,
|
|
1194
1266
|
}
|
|
1195
1267
|
}
|
|
1196
1268
|
|
|
1197
1269
|
/// Arrow data type enum for schema definition.
|
|
1198
1270
|
///
|
|
1199
|
-
/// **
|
|
1271
|
+
/// **Beta**: Arrow Flight support is in Beta.
|
|
1200
1272
|
#[cfg(feature = "arrow-flight")]
|
|
1201
1273
|
#[napi]
|
|
1202
1274
|
pub enum ArrowDataType {
|
|
@@ -1268,7 +1340,7 @@ fn convert_arrow_data_type(dt: i32) -> RustDataType {
|
|
|
1268
1340
|
|
|
1269
1341
|
/// Arrow field definition for schema.
|
|
1270
1342
|
///
|
|
1271
|
-
/// **
|
|
1343
|
+
/// **Beta**: Arrow Flight support is in Beta.
|
|
1272
1344
|
#[cfg(feature = "arrow-flight")]
|
|
1273
1345
|
#[napi(object)]
|
|
1274
1346
|
#[derive(Debug, Clone)]
|
|
@@ -1286,8 +1358,8 @@ pub struct ArrowField {
|
|
|
1286
1358
|
/// Unlike `TableProperties` which uses Protocol Buffers, Arrow Flight streams
|
|
1287
1359
|
/// require an Arrow schema definition.
|
|
1288
1360
|
///
|
|
1289
|
-
/// **
|
|
1290
|
-
///
|
|
1361
|
+
/// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
|
|
1362
|
+
/// may still change before reaching GA.
|
|
1291
1363
|
#[cfg(feature = "arrow-flight")]
|
|
1292
1364
|
#[napi(object)]
|
|
1293
1365
|
#[derive(Debug, Clone)]
|
|
@@ -1298,24 +1370,22 @@ pub struct ArrowTableProperties {
|
|
|
1298
1370
|
pub schema_fields: Vec<ArrowField>,
|
|
1299
1371
|
}
|
|
1300
1372
|
|
|
1373
|
+
// Rust SDK 2.0 made `ArrowTableProperties` non-public; the table name and
|
|
1374
|
+
// schema are passed to `sdk.stream_builder().table(...).arrow(schema)`
|
|
1375
|
+
// directly. This helper builds just the Arrow `Schema`.
|
|
1301
1376
|
#[cfg(feature = "arrow-flight")]
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1377
|
+
fn build_arrow_schema(fields: &[ArrowField]) -> Arc<RustArrowSchema> {
|
|
1378
|
+
let rust_fields: Vec<RustField> = fields
|
|
1379
|
+
.iter()
|
|
1380
|
+
.map(|f| {
|
|
1305
1381
|
RustField::new(
|
|
1306
1382
|
&f.name,
|
|
1307
1383
|
convert_arrow_data_type(f.data_type),
|
|
1308
1384
|
f.nullable.unwrap_or(true),
|
|
1309
1385
|
)
|
|
1310
|
-
}).collect();
|
|
1311
|
-
|
|
1312
|
-
let schema = Arc::new(RustArrowSchema::new(fields));
|
|
1313
|
-
|
|
1314
|
-
Ok(RustArrowTableProperties {
|
|
1315
|
-
table_name: self.table_name.clone(),
|
|
1316
|
-
schema,
|
|
1317
1386
|
})
|
|
1318
|
-
|
|
1387
|
+
.collect();
|
|
1388
|
+
Arc::new(RustArrowSchema::new(rust_fields))
|
|
1319
1389
|
}
|
|
1320
1390
|
|
|
1321
1391
|
/// An Arrow Flight stream for ingesting Arrow RecordBatches into a Delta table.
|
|
@@ -1323,8 +1393,8 @@ impl ArrowTableProperties {
|
|
|
1323
1393
|
/// This stream provides a high-performance interface for streaming Arrow data
|
|
1324
1394
|
/// to Databricks Delta tables using the Arrow Flight protocol.
|
|
1325
1395
|
///
|
|
1326
|
-
/// **
|
|
1327
|
-
///
|
|
1396
|
+
/// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
|
|
1397
|
+
/// may still change before reaching GA.
|
|
1328
1398
|
///
|
|
1329
1399
|
/// # Lifecycle
|
|
1330
1400
|
///
|
|
@@ -1358,26 +1428,6 @@ pub struct ZerobusArrowStream {
|
|
|
1358
1428
|
schema: Arc<RustArrowSchema>,
|
|
1359
1429
|
}
|
|
1360
1430
|
|
|
1361
|
-
/// Helper to parse Arrow IPC buffer to RecordBatch
|
|
1362
|
-
#[cfg(feature = "arrow-flight")]
|
|
1363
|
-
fn parse_arrow_ipc_to_batch(ipc_buffer: &[u8], _expected_schema: &RustArrowSchema) -> Result<RustRecordBatch> {
|
|
1364
|
-
let cursor = Cursor::new(ipc_buffer);
|
|
1365
|
-
let reader = StreamReader::try_new(cursor, None)
|
|
1366
|
-
.map_err(|e| Error::from_reason(format!("Failed to parse Arrow IPC: {}", e)))?;
|
|
1367
|
-
|
|
1368
|
-
// Collect all batches (typically just one)
|
|
1369
|
-
let batches: Vec<RustRecordBatch> = reader
|
|
1370
|
-
.filter_map(|r| r.ok())
|
|
1371
|
-
.collect();
|
|
1372
|
-
|
|
1373
|
-
if batches.is_empty() {
|
|
1374
|
-
return Err(Error::from_reason("Arrow IPC buffer contains no record batches"));
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
// Return the first batch (or could concatenate if multiple)
|
|
1378
|
-
Ok(batches.into_iter().next().unwrap())
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
1431
|
/// Helper to serialize RecordBatch to Arrow IPC buffer
|
|
1382
1432
|
#[cfg(feature = "arrow-flight")]
|
|
1383
1433
|
fn serialize_batch_to_ipc(batch: &RustRecordBatch) -> Result<Vec<u8>> {
|
|
@@ -1385,9 +1435,11 @@ fn serialize_batch_to_ipc(batch: &RustRecordBatch) -> Result<Vec<u8>> {
|
|
|
1385
1435
|
{
|
|
1386
1436
|
let mut writer = StreamWriter::try_new(&mut buffer, batch.schema().as_ref())
|
|
1387
1437
|
.map_err(|e| Error::from_reason(format!("Failed to create Arrow IPC writer: {}", e)))?;
|
|
1388
|
-
writer
|
|
1438
|
+
writer
|
|
1439
|
+
.write(batch)
|
|
1389
1440
|
.map_err(|e| Error::from_reason(format!("Failed to write batch to IPC: {}", e)))?;
|
|
1390
|
-
writer
|
|
1441
|
+
writer
|
|
1442
|
+
.finish()
|
|
1391
1443
|
.map_err(|e| Error::from_reason(format!("Failed to finish IPC stream: {}", e)))?;
|
|
1392
1444
|
}
|
|
1393
1445
|
Ok(buffer)
|
|
@@ -1423,29 +1475,28 @@ impl ZerobusArrowStream {
|
|
|
1423
1475
|
/// ```
|
|
1424
1476
|
#[napi(ts_return_type = "Promise<bigint>")]
|
|
1425
1477
|
pub fn ingest_batch(&self, env: Env, ipc_buffer: Buffer) -> Result<JsObject> {
|
|
1426
|
-
|
|
1478
|
+
// The Rust SDK's `ingest_ipc_batch` materialises the bytes into a
|
|
1479
|
+
// `RecordBatch`, rejects multi-batch streams, and validates the schema
|
|
1480
|
+
// against the stream's schema. No need to duplicate any of that here.
|
|
1427
1481
|
let stream = self.inner.clone();
|
|
1428
1482
|
let buffer_vec = ipc_buffer.to_vec();
|
|
1429
1483
|
|
|
1430
1484
|
env.execute_tokio_future(
|
|
1431
1485
|
async move {
|
|
1432
|
-
let batch = parse_arrow_ipc_to_batch(&buffer_vec, &schema)?;
|
|
1433
|
-
|
|
1434
1486
|
let mut guard = stream.lock().await;
|
|
1435
1487
|
let stream_ref = guard
|
|
1436
1488
|
.as_mut()
|
|
1437
1489
|
.ok_or_else(|| napi::Error::from_reason("Arrow stream has been closed"))?;
|
|
1438
1490
|
|
|
1439
1491
|
stream_ref
|
|
1440
|
-
.
|
|
1492
|
+
.ingest_ipc_batch(Bytes::from(buffer_vec))
|
|
1441
1493
|
.await
|
|
1442
1494
|
.map_err(|e| napi::Error::from_reason(format!("Failed to ingest batch: {}", e)))
|
|
1443
1495
|
},
|
|
1444
1496
|
|env, offset_id| {
|
|
1445
|
-
let offset_str = offset_id.to_string();
|
|
1446
1497
|
let global: JsGlobal = env.get_global()?;
|
|
1447
1498
|
let bigint_ctor: JsFunction = global.get_named_property("BigInt")?;
|
|
1448
|
-
let js_str = env.create_string(&
|
|
1499
|
+
let js_str = env.create_string(&offset_id.to_string())?;
|
|
1449
1500
|
bigint_ctor.call(None, &[js_str.into_unknown()])
|
|
1450
1501
|
},
|
|
1451
1502
|
)
|
|
@@ -1458,12 +1509,9 @@ impl ZerobusArrowStream {
|
|
|
1458
1509
|
/// # Arguments
|
|
1459
1510
|
///
|
|
1460
1511
|
/// * `offset_id` - The offset ID to wait for (returned by ingestBatch)
|
|
1461
|
-
#[napi(
|
|
1462
|
-
pub fn wait_for_offset(&self, env: Env, offset_id:
|
|
1463
|
-
let
|
|
1464
|
-
let number_ctor: JsFunction = global.get_named_property("Number")?;
|
|
1465
|
-
let num_result: JsUnknown = number_ctor.call(None, &[offset_id])?;
|
|
1466
|
-
let offset: i64 = num_result.coerce_to_number()?.get_int64()?;
|
|
1512
|
+
#[napi(ts_return_type = "Promise<void>")]
|
|
1513
|
+
pub fn wait_for_offset(&self, env: Env, offset_id: BigInt) -> Result<JsObject> {
|
|
1514
|
+
let offset = bigint_to_i64(offset_id)?;
|
|
1467
1515
|
|
|
1468
1516
|
let stream = self.inner.clone();
|
|
1469
1517
|
|
|
@@ -1474,10 +1522,9 @@ impl ZerobusArrowStream {
|
|
|
1474
1522
|
.as_ref()
|
|
1475
1523
|
.ok_or_else(|| napi::Error::from_reason("Arrow stream has been closed"))?;
|
|
1476
1524
|
|
|
1477
|
-
stream_ref
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
.map_err(|e| napi::Error::from_reason(format!("Failed to wait for offset: {}", e)))
|
|
1525
|
+
stream_ref.wait_for_offset(offset).await.map_err(|e| {
|
|
1526
|
+
napi::Error::from_reason(format!("Failed to wait for offset: {}", e))
|
|
1527
|
+
})
|
|
1481
1528
|
},
|
|
1482
1529
|
|_env, _| Ok(()),
|
|
1483
1530
|
)
|
|
@@ -1523,7 +1570,9 @@ impl ZerobusArrowStream {
|
|
|
1523
1570
|
/// Returns the table name for this stream.
|
|
1524
1571
|
#[napi(getter)]
|
|
1525
1572
|
pub fn table_name(&self) -> Result<String> {
|
|
1526
|
-
let guard = self
|
|
1573
|
+
let guard = self
|
|
1574
|
+
.inner
|
|
1575
|
+
.try_lock()
|
|
1527
1576
|
.map_err(|_| Error::from_reason("Stream is busy"))?;
|
|
1528
1577
|
let stream = guard
|
|
1529
1578
|
.as_ref()
|
|
@@ -1568,8 +1617,8 @@ impl ZerobusArrowStream {
|
|
|
1568
1617
|
impl ZerobusSdk {
|
|
1569
1618
|
/// Creates a new Arrow Flight stream to a Delta table.
|
|
1570
1619
|
///
|
|
1571
|
-
/// **
|
|
1572
|
-
///
|
|
1620
|
+
/// **Beta**: Arrow Flight support is in Beta. The API is stabilising
|
|
1621
|
+
/// but may still change before reaching GA.
|
|
1573
1622
|
///
|
|
1574
1623
|
/// This method establishes an Arrow Flight connection to the Zerobus service
|
|
1575
1624
|
/// for high-performance columnar data ingestion.
|
|
@@ -1613,22 +1662,69 @@ impl ZerobusSdk {
|
|
|
1613
1662
|
client_secret: String,
|
|
1614
1663
|
options: Option<ArrowStreamConfigurationOptions>,
|
|
1615
1664
|
) -> Result<JsObject> {
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1665
|
+
// Rust SDK 2.0 removed the convenience `create_arrow_stream` method;
|
|
1666
|
+
// open via `sdk.stream_builder().table(...).oauth(...).arrow(schema)
|
|
1667
|
+
// .build_arrow()` and apply options via setters. `ArrowTableProperties`
|
|
1668
|
+
// is also private now, so we build just the schema here.
|
|
1669
|
+
let schema = build_arrow_schema(&table_properties.schema_fields);
|
|
1670
|
+
let table_name = table_properties.table_name.clone();
|
|
1671
|
+
let opts = options.unwrap_or(ArrowStreamConfigurationOptions {
|
|
1672
|
+
max_inflight_batches: None,
|
|
1673
|
+
recovery: None,
|
|
1674
|
+
recovery_timeout_ms: None,
|
|
1675
|
+
recovery_backoff_ms: None,
|
|
1676
|
+
recovery_retries: None,
|
|
1677
|
+
server_lack_of_ack_timeout_ms: None,
|
|
1678
|
+
flush_timeout_ms: None,
|
|
1679
|
+
connection_timeout_ms: None,
|
|
1680
|
+
ipc_compression: None,
|
|
1681
|
+
});
|
|
1682
|
+
let ipc_compression = map_ipc_compression(opts.ipc_compression);
|
|
1683
|
+
let schema_for_stream = schema.clone();
|
|
1619
1684
|
|
|
1620
1685
|
let sdk = self.inner.clone();
|
|
1621
1686
|
|
|
1622
1687
|
env.execute_tokio_future(
|
|
1623
1688
|
async move {
|
|
1624
|
-
let
|
|
1625
|
-
.
|
|
1626
|
-
.
|
|
1627
|
-
.
|
|
1689
|
+
let mut builder = sdk
|
|
1690
|
+
.stream_builder()
|
|
1691
|
+
.table(table_name)
|
|
1692
|
+
.oauth(client_id, client_secret)
|
|
1693
|
+
.arrow(schema.clone());
|
|
1694
|
+
|
|
1695
|
+
if let Some(v) = opts.max_inflight_batches {
|
|
1696
|
+
builder = builder.max_inflight_batches(v as usize);
|
|
1697
|
+
}
|
|
1698
|
+
if let Some(v) = opts.recovery {
|
|
1699
|
+
builder = builder.recovery(v);
|
|
1700
|
+
}
|
|
1701
|
+
if let Some(v) = opts.recovery_timeout_ms {
|
|
1702
|
+
builder = builder.recovery_timeout_ms(v as u64);
|
|
1703
|
+
}
|
|
1704
|
+
if let Some(v) = opts.recovery_backoff_ms {
|
|
1705
|
+
builder = builder.recovery_backoff_ms(v as u64);
|
|
1706
|
+
}
|
|
1707
|
+
if let Some(v) = opts.recovery_retries {
|
|
1708
|
+
builder = builder.recovery_retries(v);
|
|
1709
|
+
}
|
|
1710
|
+
if let Some(v) = opts.server_lack_of_ack_timeout_ms {
|
|
1711
|
+
builder = builder.server_lack_of_ack_timeout_ms(v as u64);
|
|
1712
|
+
}
|
|
1713
|
+
if let Some(v) = opts.flush_timeout_ms {
|
|
1714
|
+
builder = builder.flush_timeout_ms(v as u64);
|
|
1715
|
+
}
|
|
1716
|
+
if let Some(v) = opts.connection_timeout_ms {
|
|
1717
|
+
builder = builder.connection_timeout_ms(v as u64);
|
|
1718
|
+
}
|
|
1719
|
+
builder = builder.ipc_compression(ipc_compression);
|
|
1720
|
+
|
|
1721
|
+
let stream = builder.build_arrow().await.map_err(|e| {
|
|
1722
|
+
napi::Error::from_reason(format!("Failed to create arrow stream: {}", e))
|
|
1723
|
+
})?;
|
|
1628
1724
|
|
|
1629
1725
|
Ok(ZerobusArrowStream {
|
|
1630
1726
|
inner: Arc::new(Mutex::new(Some(stream))),
|
|
1631
|
-
schema,
|
|
1727
|
+
schema: schema_for_stream,
|
|
1632
1728
|
})
|
|
1633
1729
|
},
|
|
1634
1730
|
|_env, stream| Ok(stream),
|
|
@@ -1637,17 +1733,21 @@ impl ZerobusSdk {
|
|
|
1637
1733
|
|
|
1638
1734
|
/// Recreates an Arrow stream with the same configuration and re-ingests unacknowledged batches.
|
|
1639
1735
|
///
|
|
1640
|
-
/// **
|
|
1736
|
+
/// **Beta**: Arrow Flight support is in Beta.
|
|
1641
1737
|
///
|
|
1642
1738
|
/// # Arguments
|
|
1643
1739
|
///
|
|
1644
|
-
/// * `stream` - The failed
|
|
1740
|
+
/// * `stream` - The terminally failed Arrow stream to recreate. The TypeScript wrapper
|
|
1741
|
+
/// must not have been closed because `close()` releases its native handle.
|
|
1645
1742
|
///
|
|
1646
1743
|
/// # Returns
|
|
1647
1744
|
///
|
|
1648
1745
|
/// A Promise that resolves to a new ZerobusArrowStream with all unacknowledged batches re-ingested.
|
|
1649
1746
|
#[napi]
|
|
1650
|
-
pub async fn recreate_arrow_stream(
|
|
1747
|
+
pub async fn recreate_arrow_stream(
|
|
1748
|
+
&self,
|
|
1749
|
+
stream: &ZerobusArrowStream,
|
|
1750
|
+
) -> Result<ZerobusArrowStream> {
|
|
1651
1751
|
let inner_guard = stream.inner.lock().await;
|
|
1652
1752
|
let rust_stream = inner_guard
|
|
1653
1753
|
.as_ref()
|