@databricks/zerobus-ingest-sdk 1.1.0 → 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/src/lib.rs CHANGED
@@ -50,7 +50,7 @@ pub enum RecordType {
50
50
  #[napi(object)]
51
51
  pub struct StreamConfigurationOptions {
52
52
  /// Maximum number of unacknowledged requests that can be in flight.
53
- /// Default: 10,000
53
+ /// Default: 1,000,000
54
54
  pub max_inflight_requests: Option<u32>,
55
55
 
56
56
  /// Enable automatic stream recovery on transient failures.
@@ -103,7 +103,8 @@ pub struct TableProperties {
103
103
  pub table_name: String,
104
104
 
105
105
  /// Optional Protocol Buffer descriptor as a base64-encoded string.
106
- /// If not provided, JSON encoding will be used.
106
+ /// Omitting this does not select JSON. The stream defaults to Protocol Buffers
107
+ /// unless `record_type` is set to JSON.
107
108
  pub descriptor_proto: Option<String>,
108
109
  }
109
110
 
@@ -220,8 +221,8 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
220
221
  ///
221
222
  /// ```typescript
222
223
  /// const stream = await sdk.createStream(tableProps, clientId, clientSecret, options);
223
- /// const ackPromise = await stream.ingestRecord(Buffer.from([1, 2, 3]));
224
- /// const offset = await ackPromise;
224
+ /// const offset = await stream.ingestRecordOffset(Buffer.from([1, 2, 3]));
225
+ /// await stream.flush();
225
226
  /// await stream.close();
226
227
  /// ```
227
228
  #[napi]
@@ -400,6 +401,13 @@ impl ZerobusStream {
400
401
  /// This is the recommended API for high-throughput scenarios where you want to
401
402
  /// decouple record ingestion from acknowledgment tracking.
402
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
+ ///
403
411
  /// # Arguments
404
412
  ///
405
413
  /// * `payload` - The record data (Buffer, string, protobuf message, or plain object)
@@ -412,11 +420,14 @@ impl ZerobusStream {
412
420
  /// # Example
413
421
  ///
414
422
  /// ```typescript
415
- /// // Promise resolves immediately with offset (before server ack)
416
- /// const offset1 = await stream.ingestRecordOffset(record1);
417
- /// const offset2 = await stream.ingestRecordOffset(record2);
418
- /// // Wait for both to be acknowledged
419
- /// await stream.waitForOffset(offset2);
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();
420
431
  /// ```
421
432
  #[napi(ts_return_type = "Promise<bigint>")]
422
433
  pub fn ingest_record_offset(&self, env: Env, payload: Unknown) -> Result<JsObject> {
@@ -454,6 +465,12 @@ impl ZerobusStream {
454
465
  /// the batch is queued, without waiting for server acknowledgment. Use
455
466
  /// `waitForOffset()` to wait for acknowledgment when needed.
456
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
+ ///
457
474
  /// # Arguments
458
475
  ///
459
476
  /// * `records` - Array of record data
@@ -466,11 +483,14 @@ impl ZerobusStream {
466
483
  /// # Example
467
484
  ///
468
485
  /// ```typescript
469
- /// // Promise resolves immediately with offset (before server ack)
470
- /// const offset = await stream.ingestRecordsOffset(batch);
471
- /// if (offset !== null) {
472
- /// await stream.waitForOffset(offset);
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;
473
491
  /// }
492
+ /// if (lastOffset !== null) await stream.waitForOffset(lastOffset);
493
+ /// // Or simply: await stream.flush();
474
494
  /// ```
475
495
  #[napi(ts_return_type = "Promise<bigint | null>")]
476
496
  pub fn ingest_records_offset(&self, env: Env, records: Vec<Unknown>) -> Result<JsObject> {
@@ -511,9 +531,12 @@ impl ZerobusStream {
511
531
 
512
532
  /// Waits for a specific offset to be acknowledged by the server.
513
533
  ///
514
- /// Use this method with `ingestRecordOffset()` and `ingestRecordsOffset()` to
515
- /// selectively wait for acknowledgments. This allows you to ingest many records
516
- /// quickly and then wait only for specific offsets when needed.
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.
517
540
  ///
518
541
  /// # Arguments
519
542
  ///
@@ -527,12 +550,12 @@ impl ZerobusStream {
527
550
  /// # Example
528
551
  ///
529
552
  /// ```typescript
530
- /// const offsets = [];
553
+ /// let lastOffset: bigint | null = null;
531
554
  /// for (const record of records) {
532
- /// offsets.push(await stream.ingestRecordOffset(record));
555
+ /// lastOffset = await stream.ingestRecordOffset(record); // no per-record wait
533
556
  /// }
534
- /// // Wait for the last offset (implies all previous are also acknowledged)
535
- /// await stream.waitForOffset(offsets[offsets.length - 1]);
557
+ /// // Wait for the last offset only (implies all previous are also acknowledged).
558
+ /// if (lastOffset !== null) await stream.waitForOffset(lastOffset);
536
559
  /// ```
537
560
  #[napi(ts_return_type = "Promise<void>")]
538
561
  pub fn wait_for_offset(&self, env: Env, offset_id: BigInt) -> Result<JsObject> {
@@ -560,6 +583,11 @@ impl ZerobusStream {
560
583
  /// This method ensures all previously ingested records have been sent to the server
561
584
  /// and acknowledged. It's useful for checkpointing or ensuring data durability.
562
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
+ ///
563
591
  /// # Errors
564
592
  ///
565
593
  /// - Timeout errors if flush takes longer than configured timeout
@@ -646,15 +674,12 @@ impl ZerobusStream {
646
674
  ///
647
675
  /// ```typescript
648
676
  /// try {
649
- /// await stream.ingestRecords(batch1);
650
- /// await stream.ingestRecords(batch2);
677
+ /// await stream.ingestRecordsOffset(batch1);
678
+ /// await stream.ingestRecordsOffset(batch2);
679
+ /// await stream.flush();
651
680
  /// } catch (error) {
652
681
  /// const unackedBatches = await stream.getUnackedBatches();
653
- ///
654
- /// // Re-ingest with new stream
655
- /// for (const batch of unackedBatches) {
656
- /// await newStream.ingestRecords(batch);
657
- /// }
682
+ /// console.log(`Batches available for recovery: ${unackedBatches.length}`);
658
683
  /// }
659
684
  /// ```
660
685
  #[napi]
@@ -687,10 +712,10 @@ impl ZerobusStream {
687
712
  /// JavaScript headers provider callback wrapper.
688
713
  ///
689
714
  /// Allows TypeScript code to provide custom authentication headers
690
- /// by implementing a getHeaders() function.
715
+ /// by implementing a getHeadersCallback() function.
691
716
  #[napi(object)]
692
717
  pub struct JsHeadersProvider {
693
- /// JavaScript function: () => Promise<Array<[string, string]>>
718
+ /// JavaScript function: () => Array<[string, string]>
694
719
  pub get_headers_callback: JsFunction,
695
720
  }
696
721
 
@@ -721,11 +746,6 @@ impl StaticHeadersProvider {
721
746
  ));
722
747
  }
723
748
 
724
- // Add TS user agent if not provided
725
- if !map.contains_key("user-agent") {
726
- map.insert("user-agent", TS_SDK_USER_AGENT.to_string());
727
- }
728
-
729
749
  Ok(Self { headers: map })
730
750
  }
731
751
  }
@@ -810,11 +830,17 @@ impl RustHeadersProvider for TsOAuthHeadersProvider {
810
830
  let mut headers = HashMap::new();
811
831
  headers.insert("authorization", format!("Bearer {}", token));
812
832
  headers.insert("x-databricks-zerobus-table-name", self.table_name.clone());
813
- headers.insert("user-agent", TS_SDK_USER_AGENT.to_string());
814
833
  Ok(headers)
815
834
  }
816
835
  }
817
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
+
818
844
  /// The main SDK for interacting with the Databricks Zerobus service.
819
845
  ///
820
846
  /// This is the entry point for creating ingestion streams to Delta tables.
@@ -824,7 +850,8 @@ impl RustHeadersProvider for TsOAuthHeadersProvider {
824
850
  /// ```typescript
825
851
  /// const sdk = new ZerobusSdk(
826
852
  /// "https://workspace-id.zerobus.region.cloud.databricks.com",
827
- /// "https://workspace.cloud.databricks.com"
853
+ /// "https://workspace.cloud.databricks.com",
854
+ /// { applicationName: "my-app/1.0" }
828
855
  /// );
829
856
  ///
830
857
  /// const stream = await sdk.createStream(
@@ -852,13 +879,19 @@ impl ZerobusSdk {
852
879
  /// (e.g., "https://workspace-id.zerobus.region.cloud.databricks.com")
853
880
  /// * `unity_catalog_url` - The Unity Catalog endpoint URL
854
881
  /// (e.g., "https://workspace.cloud.databricks.com")
882
+ /// * `options` - Optional SDK configuration (see `ZerobusSdkOptions`),
883
+ /// including `applicationName` for server-side attribution.
855
884
  ///
856
885
  /// # Errors
857
886
  ///
858
887
  /// - Invalid endpoint URLs
859
888
  /// - Failed to extract workspace ID from the endpoint
860
889
  #[napi(constructor)]
861
- pub fn new(zerobus_endpoint: String, unity_catalog_url: String) -> Result<Self> {
890
+ pub fn new(
891
+ zerobus_endpoint: String,
892
+ unity_catalog_url: String,
893
+ options: Option<ZerobusSdkOptions>,
894
+ ) -> Result<Self> {
862
895
  let workspace_id = zerobus_endpoint
863
896
  .strip_prefix("https://")
864
897
  .or_else(|| zerobus_endpoint.strip_prefix("http://"))
@@ -870,9 +903,17 @@ impl ZerobusSdk {
870
903
  )
871
904
  })?;
872
905
 
873
- let inner = RustZerobusSdk::builder()
906
+ let options = options.unwrap_or_default();
907
+
908
+ let builder = RustZerobusSdk::builder()
874
909
  .endpoint(&zerobus_endpoint)
875
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
876
917
  .build()
877
918
  .map_err(|e| Error::from_reason(format!("Failed to create SDK: {}", e)))?;
878
919
 
@@ -927,7 +968,7 @@ impl ZerobusSdk {
927
968
  /// "", // ignored
928
969
  /// undefined,
929
970
  /// {
930
- /// getHeadersCallback: async () => [
971
+ /// getHeadersCallback: () => [
931
972
  /// ["authorization", `Bearer ${myToken}`],
932
973
  /// ["x-databricks-zerobus-table-name", tableName]
933
974
  /// ]
@@ -947,19 +988,19 @@ impl ZerobusSdk {
947
988
  // Decode the optional protobuf descriptor up-front so we can hand it
948
989
  // to the builder's `.compiled_proto()` setter; the builder constructs
949
990
  // the (now-private) `TableProperties` itself.
950
- let descriptor_proto: Option<prost_types::DescriptorProto> =
951
- if let Some(ref desc_str) = table_properties.descriptor_proto {
952
- let bytes = base64_decode(desc_str).map_err(|e| {
953
- Error::from_reason(format!("Failed to decode descriptor: {}", e))
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))
954
999
  })?;
955
- let dp: prost_types::DescriptorProto = prost::Message::decode(&bytes[..])
956
- .map_err(|e| {
957
- Error::from_reason(format!("Failed to parse descriptor proto: {}", e))
958
- })?;
959
- Some(dp)
960
- } else {
961
- None
962
- };
1000
+ Some(dp)
1001
+ } else {
1002
+ None
1003
+ };
963
1004
 
964
1005
  let opts = options.unwrap_or(StreamConfigurationOptions {
965
1006
  max_inflight_requests: None,
@@ -1076,7 +1117,8 @@ impl ZerobusSdk {
1076
1117
  ///
1077
1118
  /// # Arguments
1078
1119
  ///
1079
- /// * `stream` - The failed or closed stream to recreate
1120
+ /// * `stream` - The terminally failed stream to recreate. The TypeScript wrapper
1121
+ /// must not have been closed because `close()` releases its native handle.
1080
1122
  ///
1081
1123
  /// # Returns
1082
1124
  ///
@@ -1092,12 +1134,23 @@ impl ZerobusSdk {
1092
1134
  ///
1093
1135
  /// ```typescript
1094
1136
  /// try {
1095
- /// await stream.ingestRecords(batch);
1137
+ /// await stream.ingestRecordsOffset(batch);
1138
+ /// await stream.flush();
1096
1139
  /// } catch (error) {
1097
- /// await stream.close();
1098
- /// // Recreate stream with all unacked batches re-ingested
1099
- /// const newStream = await sdk.recreateStream(stream);
1100
- /// // Continue ingesting with newStream
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
+ /// }
1101
1154
  /// }
1102
1155
  /// ```
1103
1156
  #[napi]
@@ -1438,9 +1491,7 @@ impl ZerobusArrowStream {
1438
1491
  stream_ref
1439
1492
  .ingest_ipc_batch(Bytes::from(buffer_vec))
1440
1493
  .await
1441
- .map_err(|e| {
1442
- napi::Error::from_reason(format!("Failed to ingest batch: {}", e))
1443
- })
1494
+ .map_err(|e| napi::Error::from_reason(format!("Failed to ingest batch: {}", e)))
1444
1495
  },
1445
1496
  |env, offset_id| {
1446
1497
  let global: JsGlobal = env.get_global()?;
@@ -1686,7 +1737,8 @@ impl ZerobusSdk {
1686
1737
  ///
1687
1738
  /// # Arguments
1688
1739
  ///
1689
- /// * `stream` - The failed or closed Arrow stream to recreate
1740
+ /// * `stream` - The terminally failed Arrow stream to recreate. The TypeScript wrapper
1741
+ /// must not have been closed because `close()` releases its native handle.
1690
1742
  ///
1691
1743
  /// # Returns
1692
1744
  ///
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Options for loading a descriptor from a FileDescriptorSet.
3
+ */
4
+ export interface LoadDescriptorOptions {
5
+ /**
6
+ * Path to the descriptor file generated by protoc.
7
+ * Example: 'schemas/my_schema_descriptor.pb'
8
+ */
9
+ descriptorPath: string;
10
+
11
+ /**
12
+ * Name of the proto file within the FileDescriptorSet.
13
+ * This should match the filename in your .proto file.
14
+ * Example: 'schemas/air_quality.proto' or 'air_quality.proto'
15
+ */
16
+ protoFileName: string;
17
+
18
+ /**
19
+ * Name of the message type to extract.
20
+ * Example: 'AirQuality'
21
+ */
22
+ messageName: string;
23
+ }
24
+
25
+ /**
26
+ * Loads a specific message's DescriptorProto from a FileDescriptorSet.
27
+ *
28
+ * @param options - Configuration specifying which message to extract
29
+ * @returns Base64-encoded DescriptorProto string for the specified message
30
+ * @throws Error if the descriptor file, proto file, or message cannot be found
31
+ */
32
+ export function loadDescriptorProto(options: LoadDescriptorOptions): string;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Utility functions for working with Protocol Buffer descriptors.
5
+ *
6
+ * These utilities help extract message descriptors from FileDescriptorSets
7
+ * generated by protoc, providing flexibility in choosing which message to use.
8
+ */
9
+
10
+ const fs = require("fs");
11
+ const descriptor = require("protobufjs/ext/descriptor");
12
+
13
+ /**
14
+ * Loads a specific message's DescriptorProto from a FileDescriptorSet.
15
+ *
16
+ * @param {object} options
17
+ * @param {string} options.descriptorPath Path to the descriptor file generated by protoc.
18
+ * @param {string} options.protoFileName Name of the proto file within the FileDescriptorSet.
19
+ * @param {string} options.messageName Name of the message type to extract.
20
+ * @returns {string} Base64-encoded DescriptorProto string for the specified message.
21
+ */
22
+ function loadDescriptorProto(options) {
23
+ const { descriptorPath, protoFileName, messageName } = options;
24
+
25
+ const descriptorBytes = fs.readFileSync(descriptorPath);
26
+ const fileDescriptorSet = descriptor.FileDescriptorSet.decode(descriptorBytes);
27
+ const fileDescriptor = fileDescriptorSet.file.find((file) =>
28
+ file.name === protoFileName ||
29
+ file.name.endsWith("/" + protoFileName)
30
+ );
31
+
32
+ if (!fileDescriptor) {
33
+ throw new Error(
34
+ `Proto file '${protoFileName}' not found in descriptor. Available files: ${
35
+ fileDescriptorSet.file.map((file) => file.name).join(", ")
36
+ }`
37
+ );
38
+ }
39
+
40
+ const messageType = fileDescriptor.messageType?.find((message) => message.name === messageName);
41
+
42
+ if (!messageType) {
43
+ const availableMessages = fileDescriptor.messageType?.map((message) => message.name).join(", ") || "none";
44
+ throw new Error(
45
+ `Message '${messageName}' not found in ${protoFileName}. Available messages: ${availableMessages}`
46
+ );
47
+ }
48
+
49
+ const descriptorProtoBytes = descriptor.DescriptorProto.encode(messageType).finish();
50
+ return Buffer.from(descriptorProtoBytes).toString("base64");
51
+ }
52
+
53
+ module.exports = {
54
+ loadDescriptorProto,
55
+ };
@@ -49,7 +49,7 @@ export interface LoadDescriptorOptions {
49
49
  *
50
50
  * @example
51
51
  * ```typescript
52
- * import { loadDescriptorProto } from '@databricks/zerobus-sdk/utils/descriptor';
52
+ * import { loadDescriptorProto } from '@databricks/zerobus-ingest-sdk/utils/descriptor.js';
53
53
  *
54
54
  * const descriptorBase64 = loadDescriptorProto({
55
55
  * descriptorPath: 'schemas/air_quality_descriptor.pb',
@@ -74,7 +74,7 @@ export function loadDescriptorProto(options: LoadDescriptorOptions): string {
74
74
 
75
75
  // Find the file descriptor matching the proto file name
76
76
  const fileDescriptor = fileDescriptorSet.file.find((f: any) =>
77
- f.name === protoFileName || f.name.endsWith('/' + protoFileName) || f.name.endsWith(protoFileName)
77
+ f.name === protoFileName || f.name.endsWith('/' + protoFileName)
78
78
  );
79
79
 
80
80
  if (!fileDescriptor) {