snowpipe-streaming 1.6.1 → 1.7.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/package.json +7 -7
- package/src/channel.js +8 -35
- package/src/client.js +88 -11
- package/src/elastic_channel.js +175 -0
- package/src/index.d.ts +98 -0
- package/src/index.js +8 -1
- package/src/mark_complete_callback.js +157 -0
- package/src/row_serializer.js +62 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snowpipe-streaming",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Snowflake Streaming Ingest SDK for Node.js",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"typedoc": "^0.28.19"
|
|
28
28
|
},
|
|
29
29
|
"optionalDependencies": {
|
|
30
|
-
"snowpipe-streaming-linux-x64-gnu": "1.
|
|
31
|
-
"snowpipe-streaming-linux-x64-musl": "1.
|
|
32
|
-
"snowpipe-streaming-linux-arm64-gnu": "1.
|
|
33
|
-
"snowpipe-streaming-linux-arm64-musl": "1.
|
|
34
|
-
"snowpipe-streaming-darwin-arm64": "1.
|
|
35
|
-
"snowpipe-streaming-win32-x64-msvc": "1.
|
|
30
|
+
"snowpipe-streaming-linux-x64-gnu": "1.7.0",
|
|
31
|
+
"snowpipe-streaming-linux-x64-musl": "1.7.0",
|
|
32
|
+
"snowpipe-streaming-linux-arm64-gnu": "1.7.0",
|
|
33
|
+
"snowpipe-streaming-linux-arm64-musl": "1.7.0",
|
|
34
|
+
"snowpipe-streaming-darwin-arm64": "1.7.0",
|
|
35
|
+
"snowpipe-streaming-win32-x64-msvc": "1.7.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/channel.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
const { callFFI, StreamingIngestError } = require("./errors.js");
|
|
19
19
|
const { ChannelStatus } = require("./channel_status.js");
|
|
20
|
+
const { serializeRowsToNdjson } = require("./row_serializer.js");
|
|
20
21
|
|
|
21
22
|
const WAIT_FOR_COMMIT_CHECK_INTERVAL_MS = 1000;
|
|
22
23
|
|
|
@@ -71,35 +72,6 @@ class StreamingIngestChannel {
|
|
|
71
72
|
return this._native.binaryInputFormat;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
/**
|
|
75
|
-
* Convert Buffer/Uint8Array values in a row according to binaryInputFormat.
|
|
76
|
-
* For HEX, binary values become hex-encoded strings.
|
|
77
|
-
* For UTF-8, binary values are decoded as UTF-8 strings.
|
|
78
|
-
* Only top-level values are converted; nested structures are left as-is.
|
|
79
|
-
* @param {Object} row - Row object
|
|
80
|
-
* @returns {Object} Row with binary values converted
|
|
81
|
-
* @private
|
|
82
|
-
*/
|
|
83
|
-
_convertBytesInRow(row) {
|
|
84
|
-
const result = {};
|
|
85
|
-
for (const [key, value] of Object.entries(row)) {
|
|
86
|
-
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
87
|
-
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
88
|
-
if (this.binaryInputFormat === "HEX") {
|
|
89
|
-
result[key] = buf.toString("hex");
|
|
90
|
-
} else if (this.binaryInputFormat === "UTF-8") {
|
|
91
|
-
result[key] = buf.toString("utf8");
|
|
92
|
-
} else {
|
|
93
|
-
// BASE64: convert to base64 string for JSON serialization
|
|
94
|
-
result[key] = buf.toString("base64");
|
|
95
|
-
}
|
|
96
|
-
} else {
|
|
97
|
-
result[key] = value;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return result;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
75
|
/**
|
|
104
76
|
* Append a single row. Convenience wrapper for appendRows.
|
|
105
77
|
* @param {Object} row - Row data as key-value pairs
|
|
@@ -108,6 +80,9 @@ class StreamingIngestChannel {
|
|
|
108
80
|
* @throws {StreamingIngestError} If the row appending fails
|
|
109
81
|
*/
|
|
110
82
|
appendRow(row, offsetToken) {
|
|
83
|
+
if (row == null) {
|
|
84
|
+
throw new TypeError("row must not be null");
|
|
85
|
+
}
|
|
111
86
|
this.appendRows([row], offsetToken, offsetToken);
|
|
112
87
|
}
|
|
113
88
|
|
|
@@ -122,17 +97,15 @@ class StreamingIngestChannel {
|
|
|
122
97
|
* @throws {StreamingIngestError} If the rows appending fails
|
|
123
98
|
*/
|
|
124
99
|
appendRows(rows, startOffsetToken, endOffsetToken) {
|
|
100
|
+
if (rows == null) {
|
|
101
|
+
throw new TypeError("rows must not be null");
|
|
102
|
+
}
|
|
125
103
|
if (!Array.isArray(rows)) {
|
|
126
104
|
throw new TypeError("rows must be an array");
|
|
127
105
|
}
|
|
128
106
|
// Empty arrays are handled by the Rust core, which returns a proper InvalidRequest error.
|
|
129
107
|
const buffer =
|
|
130
|
-
rows.length === 0
|
|
131
|
-
? Buffer.alloc(0)
|
|
132
|
-
: Buffer.from(
|
|
133
|
-
rows.map((r) => JSON.stringify(this._convertBytesInRow(r))).join("\n"),
|
|
134
|
-
"utf8",
|
|
135
|
-
);
|
|
108
|
+
rows.length === 0 ? Buffer.alloc(0) : serializeRowsToNdjson(rows, this.binaryInputFormat);
|
|
136
109
|
try {
|
|
137
110
|
this._native.appendRows(buffer, rows.length, startOffsetToken, endOffsetToken);
|
|
138
111
|
} catch (e) {
|
package/src/client.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
const { callFFI, StreamingIngestError } = require("./errors.js");
|
|
19
19
|
const { ChannelStatus } = require("./channel_status.js");
|
|
20
20
|
const { StreamingIngestChannel } = require("./channel.js");
|
|
21
|
+
const { StreamingIngestElasticChannel } = require("./elastic_channel.js");
|
|
22
|
+
const { ElasticChannelAckState } = require("./mark_complete_callback.js");
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Streaming ingest client for creating channels to ingest data into Snowflake.
|
|
@@ -45,6 +47,8 @@ class StreamingIngestClient {
|
|
|
45
47
|
/** @param {object} nativeClient - Internal JsClient from FFI */
|
|
46
48
|
constructor(nativeClient) {
|
|
47
49
|
this._native = nativeClient;
|
|
50
|
+
/** @type {Promise<StreamingIngestElasticChannel>|null} Cached elastic-channel promise (singleton). */
|
|
51
|
+
this._elasticChannelPromise = null;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/** @returns {string} The client name */
|
|
@@ -108,6 +112,37 @@ class StreamingIngestClient {
|
|
|
108
112
|
};
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Get the elastic channel for this client. Elastic channels have no offset
|
|
117
|
+
* tokens and their lifecycle is tied to the client (no close method). The same
|
|
118
|
+
* instance is returned on repeated calls (singleton).
|
|
119
|
+
*
|
|
120
|
+
* @returns {Promise<StreamingIngestElasticChannel>}
|
|
121
|
+
*/
|
|
122
|
+
async getElasticChannel() {
|
|
123
|
+
if (!this._elasticChannelPromise) {
|
|
124
|
+
// Cache the in-flight promise so concurrent callers share one open and one
|
|
125
|
+
// instance. The pending-futures map is shared between the FFI callbacks and
|
|
126
|
+
// the channel wrapper (the JS side owns per-message-ack promise resolution).
|
|
127
|
+
this._elasticChannelPromise = (async () => {
|
|
128
|
+
const pendingFutures = new Map();
|
|
129
|
+
const ackState = new ElasticChannelAckState(pendingFutures);
|
|
130
|
+
const nativeChannel = await callFFI(() =>
|
|
131
|
+
this._native.getElasticChannel(ackState.markComplete, ackState.invalidateAll),
|
|
132
|
+
);
|
|
133
|
+
// Plug the native handle in before exposing the channel so the ack
|
|
134
|
+
// state can drive `invalidateChannel` from JS on detected anomalies.
|
|
135
|
+
ackState.setNativeChannel(nativeChannel);
|
|
136
|
+
return new StreamingIngestElasticChannel(nativeChannel, pendingFutures);
|
|
137
|
+
})().catch((e) => {
|
|
138
|
+
// Don't cache failures (e.g. closed client, transient open error) so a retry can re-open.
|
|
139
|
+
this._elasticChannelPromise = null;
|
|
140
|
+
throw e;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return this._elasticChannelPromise;
|
|
144
|
+
}
|
|
145
|
+
|
|
111
146
|
/**
|
|
112
147
|
* Drop a channel by name (server-side removal).
|
|
113
148
|
* @param {string} channelName
|
|
@@ -175,15 +210,16 @@ class StreamingIngestClient {
|
|
|
175
210
|
}
|
|
176
211
|
|
|
177
212
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* @param {object} native - The native addon instance
|
|
181
|
-
* @param {{clientName: string, dbName: string, schemaName: string, pipeName: string, profilePath?: string, properties?: Object.<string, string
|
|
213
|
+
* Shared client construction: coerce property values to strings, apply the
|
|
214
|
+
* Windows workaround, call the native factory, and wrap the result.
|
|
215
|
+
* @param {object} native - The native addon instance
|
|
216
|
+
* @param {{clientName: string, dbName: string, schemaName: string, pipeName: string, profilePath?: string, properties?: Object.<string, string>, isTableMode: boolean}} args
|
|
182
217
|
* @returns {Promise<StreamingIngestClient>}
|
|
218
|
+
* @private
|
|
183
219
|
*/
|
|
184
|
-
async function
|
|
220
|
+
async function _createNativeClient(native, args) {
|
|
185
221
|
// Coerce property values to strings (matching Python)
|
|
186
|
-
let properties =
|
|
222
|
+
let properties = args.properties;
|
|
187
223
|
if (properties) {
|
|
188
224
|
properties = Object.fromEntries(Object.entries(properties).map(([k, v]) => [k, String(v)]));
|
|
189
225
|
}
|
|
@@ -198,19 +234,60 @@ async function createClient(native, options) {
|
|
|
198
234
|
|
|
199
235
|
const nativeClient = await callFFI(() =>
|
|
200
236
|
native.createClient({
|
|
201
|
-
clientName:
|
|
202
|
-
dbName:
|
|
203
|
-
schemaName:
|
|
204
|
-
pipeName:
|
|
205
|
-
profilePath:
|
|
237
|
+
clientName: args.clientName,
|
|
238
|
+
dbName: args.dbName,
|
|
239
|
+
schemaName: args.schemaName,
|
|
240
|
+
pipeName: args.pipeName,
|
|
241
|
+
profilePath: args.profilePath,
|
|
206
242
|
properties,
|
|
243
|
+
isTableMode: args.isTableMode,
|
|
207
244
|
}),
|
|
208
245
|
);
|
|
209
246
|
|
|
210
247
|
return new StreamingIngestClient(nativeClient);
|
|
211
248
|
}
|
|
212
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Create a new streaming ingest client.
|
|
252
|
+
*
|
|
253
|
+
* @param {object} native - The native addon instance (provided by index.js)
|
|
254
|
+
* @param {{clientName: string, dbName: string, schemaName: string, pipeName: string, profilePath?: string, properties?: Object.<string, string>}} options
|
|
255
|
+
* @returns {Promise<StreamingIngestClient>}
|
|
256
|
+
*/
|
|
257
|
+
async function createClient(native, options) {
|
|
258
|
+
return _createNativeClient(native, {
|
|
259
|
+
clientName: options.clientName,
|
|
260
|
+
dbName: options.dbName,
|
|
261
|
+
schemaName: options.schemaName,
|
|
262
|
+
pipeName: options.pipeName,
|
|
263
|
+
profilePath: options.profilePath,
|
|
264
|
+
properties: options.properties,
|
|
265
|
+
isTableMode: false,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Create a new streaming ingest client in table mode. The pipe name is derived
|
|
271
|
+
* from the table name by appending "-STREAMING".
|
|
272
|
+
*
|
|
273
|
+
* @param {object} native - The native addon instance (provided by index.js)
|
|
274
|
+
* @param {{clientName: string, dbName: string, schemaName: string, tableName: string, profilePath?: string, properties?: Object.<string, string>}} options
|
|
275
|
+
* @returns {Promise<StreamingIngestClient>}
|
|
276
|
+
*/
|
|
277
|
+
async function createTableClient(native, options) {
|
|
278
|
+
return _createNativeClient(native, {
|
|
279
|
+
clientName: options.clientName,
|
|
280
|
+
dbName: options.dbName,
|
|
281
|
+
schemaName: options.schemaName,
|
|
282
|
+
pipeName: `${options.tableName}-STREAMING`,
|
|
283
|
+
profilePath: options.profilePath,
|
|
284
|
+
properties: options.properties,
|
|
285
|
+
isTableMode: true,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
213
289
|
module.exports = {
|
|
214
290
|
StreamingIngestClient,
|
|
215
291
|
createClient,
|
|
292
|
+
createTableClient,
|
|
216
293
|
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Copyright 2026 Snowflake Inc.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
// you may not use this file except in compliance with the License.
|
|
6
|
+
// You may obtain a copy of the License at
|
|
7
|
+
//
|
|
8
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
//
|
|
10
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
// See the License for the specific language governing permissions and
|
|
14
|
+
// limitations under the License.
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
const { callFFI, StreamingIngestError } = require("./errors.js");
|
|
19
|
+
const { ChannelStatus } = require("./channel_status.js");
|
|
20
|
+
const { serializeRowsToNdjson } = require("./row_serializer.js");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Elastic channel for streaming data into a Snowflake table.
|
|
24
|
+
*
|
|
25
|
+
* Unlike a regular channel, an elastic channel has no offset tokens and its
|
|
26
|
+
* lifecycle is tied to the client (no close method). appendRow/appendRows return
|
|
27
|
+
* a Promise that resolves when Snowflake acknowledges the rows.
|
|
28
|
+
*
|
|
29
|
+
* Created via {@link StreamingIngestClient#getElasticChannel}, not directly.
|
|
30
|
+
*/
|
|
31
|
+
class StreamingIngestElasticChannel {
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} nativeChannel - Internal JsElasticChannel from FFI
|
|
34
|
+
* @param {Map<number, {resolve: Function, reject: Function}>} pendingFutures
|
|
35
|
+
* Pending-promise map shared with the mark-complete callback wired into FFI.
|
|
36
|
+
*/
|
|
37
|
+
constructor(nativeChannel, pendingFutures) {
|
|
38
|
+
if (!(nativeChannel && typeof nativeChannel === "object")) {
|
|
39
|
+
throw new TypeError(
|
|
40
|
+
"StreamingIngestElasticChannel cannot be instantiated directly. " +
|
|
41
|
+
"Use client.getElasticChannel() instead.",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
this._native = nativeChannel;
|
|
45
|
+
this._pendingFutures = pendingFutures;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @returns {string} The channel name (always "ELASTIC") */
|
|
49
|
+
get channelName() {
|
|
50
|
+
return this._native.channelName;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @returns {string} The database name */
|
|
54
|
+
get dbName() {
|
|
55
|
+
return this._native.dbName;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @returns {string} The schema name */
|
|
59
|
+
get schemaName() {
|
|
60
|
+
return this._native.schemaName;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @returns {string} The pipe name */
|
|
64
|
+
get pipeName() {
|
|
65
|
+
return this._native.pipeName;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @returns {boolean} True if the channel is closed (because the client was closed) */
|
|
69
|
+
get isClosed() {
|
|
70
|
+
return this._native.isClosed;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get the binary input format for this channel.
|
|
75
|
+
* @returns {string} One of: "BASE64", "HEX", "UTF-8"
|
|
76
|
+
*/
|
|
77
|
+
get binaryInputFormat() {
|
|
78
|
+
return this._native.binaryInputFormat;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Append a single row into the elastic channel.
|
|
83
|
+
* @param {Object} row - Row data as column-name to value pairs
|
|
84
|
+
* @returns {Promise<void>} Resolves when Snowflake acknowledges the row
|
|
85
|
+
*/
|
|
86
|
+
async appendRow(row) {
|
|
87
|
+
if (row == null) {
|
|
88
|
+
throw new TypeError("row must not be null");
|
|
89
|
+
}
|
|
90
|
+
return this.appendRows([row]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Append multiple rows into the elastic channel.
|
|
95
|
+
*
|
|
96
|
+
* The native call is synchronous: it buffers the rows and returns a future id,
|
|
97
|
+
* which is registered in the shared pending map before returning to the event
|
|
98
|
+
* loop. The Promise resolves later when the mark-complete callback fires with
|
|
99
|
+
* the server acknowledgement (or rejects on error / channel invalidation).
|
|
100
|
+
*
|
|
101
|
+
* @param {Object[]} rows - Array of row objects
|
|
102
|
+
* @returns {Promise<void>} Resolves when Snowflake acknowledges the batch
|
|
103
|
+
*/
|
|
104
|
+
async appendRows(rows) {
|
|
105
|
+
if (rows == null) {
|
|
106
|
+
throw new TypeError("rows must not be null");
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(rows)) {
|
|
109
|
+
throw new TypeError("rows must be an array");
|
|
110
|
+
}
|
|
111
|
+
// Empty arrays are validated by the Rust core, which throws a proper error.
|
|
112
|
+
const buffer =
|
|
113
|
+
rows.length === 0 ? Buffer.alloc(0) : serializeRowsToNdjson(rows, this.binaryInputFormat);
|
|
114
|
+
let futureId;
|
|
115
|
+
try {
|
|
116
|
+
futureId = Number(this._native.appendRows(buffer, rows.length));
|
|
117
|
+
} catch (e) {
|
|
118
|
+
throw StreamingIngestError.from(e);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Build the promise out-of-line so we can stash {resolve, reject} into the
|
|
122
|
+
// shared map synchronously — the single-threaded event loop guarantees this
|
|
123
|
+
// happens-before any queued threadsafe-function callback runs. Map and
|
|
124
|
+
// resolve are then visible to mark_complete_callback.js (the JS owner of
|
|
125
|
+
// resolution) and the invalidate-all path on channel teardown.
|
|
126
|
+
let resolveFn;
|
|
127
|
+
let rejectFn;
|
|
128
|
+
const promise = new Promise((resolve, reject) => {
|
|
129
|
+
resolveFn = resolve;
|
|
130
|
+
rejectFn = reject;
|
|
131
|
+
});
|
|
132
|
+
const previous = this._pendingFutures.get(futureId);
|
|
133
|
+
this._pendingFutures.set(futureId, { resolve: resolveFn, reject: rejectFn });
|
|
134
|
+
if (previous !== undefined) {
|
|
135
|
+
// Mirrors Java/Python: a duplicate future id from Rust means the SDK
|
|
136
|
+
// and the core have drifted, which is fatal. Reject the displaced
|
|
137
|
+
// promise, invalidate the channel, and throw to the caller so the
|
|
138
|
+
// new promise we just stashed isn't left hanging either.
|
|
139
|
+
const errMsg = `Duplicate future_id=${futureId} in pendingFutures`;
|
|
140
|
+
const err = new StreamingIngestError("Fatal", errMsg, 500, "Internal Server Error");
|
|
141
|
+
previous.reject(err);
|
|
142
|
+
try {
|
|
143
|
+
this._native.invalidateChannel(errMsg);
|
|
144
|
+
} catch (invErr) {
|
|
145
|
+
// eslint-disable-next-line no-console
|
|
146
|
+
console.error("Elastic channel invalidate_channel FFI call failed:", invErr);
|
|
147
|
+
}
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
return promise;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Trigger a flush of this channel (non-blocking).
|
|
155
|
+
* @throws {StreamingIngestError} If the flush cannot be initiated
|
|
156
|
+
*/
|
|
157
|
+
initiateFlush() {
|
|
158
|
+
try {
|
|
159
|
+
this._native.initiateFlush();
|
|
160
|
+
} catch (e) {
|
|
161
|
+
throw StreamingIngestError.from(e);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get the current status of this channel.
|
|
167
|
+
* @returns {Promise<ChannelStatus>}
|
|
168
|
+
*/
|
|
169
|
+
async getChannelStatus() {
|
|
170
|
+
const raw = await callFFI(() => this._native.getChannelStatus());
|
|
171
|
+
return new ChannelStatus(raw);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { StreamingIngestElasticChannel };
|
package/src/index.d.ts
CHANGED
|
@@ -55,6 +55,32 @@ export function createClient(options: {
|
|
|
55
55
|
properties?: Record<string, string>;
|
|
56
56
|
}): Promise<StreamingIngestClient>;
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Create a new streaming ingest client in table mode. The pipe name is derived
|
|
60
|
+
* from the table name by appending "-STREAMING". Each client is tied to a single
|
|
61
|
+
* database, schema, and (derived) pipe.
|
|
62
|
+
*
|
|
63
|
+
* Either `profilePath` or `properties` must be provided for authentication.
|
|
64
|
+
* If both are provided, `properties` takes precedence for conflicting keys.
|
|
65
|
+
*
|
|
66
|
+
* @param options - Client configuration
|
|
67
|
+
* @throws StreamingIngestError If client creation fails
|
|
68
|
+
*/
|
|
69
|
+
export function createTableClient(options: {
|
|
70
|
+
/** A unique name to identify this client instance, used for tracking and debugging purposes. */
|
|
71
|
+
clientName: string;
|
|
72
|
+
/** The name of the Snowflake database where data will be ingested. */
|
|
73
|
+
dbName: string;
|
|
74
|
+
/** The name of the schema within the database. */
|
|
75
|
+
schemaName: string;
|
|
76
|
+
/** The table name. The pipe name is derived as `${tableName}-STREAMING`. */
|
|
77
|
+
tableName: string;
|
|
78
|
+
/** Path to a JSON file containing connection properties and authentication information. */
|
|
79
|
+
profilePath?: string;
|
|
80
|
+
/** Connection properties and authentication information. */
|
|
81
|
+
properties?: Record<string, string>;
|
|
82
|
+
}): Promise<StreamingIngestClient>;
|
|
83
|
+
|
|
58
84
|
/**
|
|
59
85
|
* A client that is the starting point for using the Streaming Ingest client APIs.
|
|
60
86
|
*
|
|
@@ -116,6 +142,15 @@ export class StreamingIngestClient {
|
|
|
116
142
|
status: ChannelStatus | null;
|
|
117
143
|
}>;
|
|
118
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Get the elastic channel for this client. Elastic channels have no offset
|
|
147
|
+
* tokens and their lifecycle is tied to the client (there is no close method).
|
|
148
|
+
* The same instance is returned on repeated calls (singleton).
|
|
149
|
+
*
|
|
150
|
+
* @throws StreamingIngestError If the client is closed or the channel cannot be opened
|
|
151
|
+
*/
|
|
152
|
+
getElasticChannel(): Promise<StreamingIngestElasticChannel>;
|
|
153
|
+
|
|
119
154
|
/**
|
|
120
155
|
* Drop a channel by name (server-side removal).
|
|
121
156
|
*
|
|
@@ -292,6 +327,67 @@ export class StreamingIngestChannel {
|
|
|
292
327
|
}): Promise<void>;
|
|
293
328
|
}
|
|
294
329
|
|
|
330
|
+
/**
|
|
331
|
+
* An elastic channel for streaming data into a Snowflake table.
|
|
332
|
+
*
|
|
333
|
+
* Unlike a regular channel, an elastic channel has no offset tokens and its
|
|
334
|
+
* lifecycle is tied to the client — there is no close method, and the same
|
|
335
|
+
* instance is returned on repeated calls to `client.getElasticChannel()`.
|
|
336
|
+
* `appendRow`/`appendRows` resolve when Snowflake acknowledges the rows.
|
|
337
|
+
*
|
|
338
|
+
* Create instances using `client.getElasticChannel()`.
|
|
339
|
+
*/
|
|
340
|
+
export class StreamingIngestElasticChannel {
|
|
341
|
+
private constructor();
|
|
342
|
+
|
|
343
|
+
/** The channel name (always "ELASTIC"). */
|
|
344
|
+
readonly channelName: string;
|
|
345
|
+
/** The database name. */
|
|
346
|
+
readonly dbName: string;
|
|
347
|
+
/** The schema name. */
|
|
348
|
+
readonly schemaName: string;
|
|
349
|
+
/** The pipe name. */
|
|
350
|
+
readonly pipeName: string;
|
|
351
|
+
/** Whether the channel is closed (because the client was closed). */
|
|
352
|
+
readonly isClosed: boolean;
|
|
353
|
+
/** The binary input format. One of: "BASE64", "HEX", "UTF-8". */
|
|
354
|
+
readonly binaryInputFormat: string;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Append a single row into the elastic channel. The returned promise resolves
|
|
358
|
+
* when Snowflake acknowledges the row.
|
|
359
|
+
*
|
|
360
|
+
* @param row - Row data as column-name to value pairs
|
|
361
|
+
* @throws StreamingIngestError If the row appending fails
|
|
362
|
+
*/
|
|
363
|
+
appendRow(row: Record<string, any>): Promise<void>;
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Append multiple rows into the elastic channel. The returned promise resolves
|
|
367
|
+
* when Snowflake acknowledges the batch.
|
|
368
|
+
*
|
|
369
|
+
* @param rows - Array of row objects (column-name to value pairs)
|
|
370
|
+
* @throws TypeError If rows is not an array
|
|
371
|
+
* @throws StreamingIngestError If the rows appending fails
|
|
372
|
+
*/
|
|
373
|
+
appendRows(rows: Record<string, any>[]): Promise<void>;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Initiate a flush of all buffered data for this channel but do not wait for
|
|
377
|
+
* the flush to complete.
|
|
378
|
+
*
|
|
379
|
+
* @throws StreamingIngestError If the flush cannot be initiated
|
|
380
|
+
*/
|
|
381
|
+
initiateFlush(): void;
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Get the current status of this channel.
|
|
385
|
+
*
|
|
386
|
+
* @throws StreamingIngestError If getting the channel status fails
|
|
387
|
+
*/
|
|
388
|
+
getChannelStatus(): Promise<ChannelStatus>;
|
|
389
|
+
}
|
|
390
|
+
|
|
295
391
|
/**
|
|
296
392
|
* Status information for a streaming ingest channel.
|
|
297
393
|
*
|
|
@@ -344,6 +440,8 @@ export class ChannelStatus {
|
|
|
344
440
|
export class StreamingIngestError extends Error {
|
|
345
441
|
/** The error code (for example, "ConfigError", "ChannelNotFound"). */
|
|
346
442
|
readonly errorCode: string;
|
|
443
|
+
/** The error message. Inherited from `Error`; redeclared for autodoc visibility. */
|
|
444
|
+
readonly message: string;
|
|
347
445
|
/** HTTP status code indicating error category. */
|
|
348
446
|
readonly httpStatusCode: number;
|
|
349
447
|
/** HTTP status name (for example, "Bad Request"). */
|
package/src/index.js
CHANGED
|
@@ -20,8 +20,13 @@
|
|
|
20
20
|
const { loadNativeAddon } = require("./loader.js");
|
|
21
21
|
const { StreamingIngestError } = require("./errors.js");
|
|
22
22
|
const { ChannelStatus } = require("./channel_status.js");
|
|
23
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
StreamingIngestClient,
|
|
25
|
+
createClient: _createClient,
|
|
26
|
+
createTableClient: _createTableClient,
|
|
27
|
+
} = require("./client.js");
|
|
24
28
|
const { StreamingIngestChannel } = require("./channel.js");
|
|
29
|
+
const { StreamingIngestElasticChannel } = require("./elastic_channel.js");
|
|
25
30
|
|
|
26
31
|
// Load native addon and bootstrap the Rust runtime
|
|
27
32
|
const native = loadNativeAddon();
|
|
@@ -41,8 +46,10 @@ const StreamingIngestErrorCode = Object.freeze(
|
|
|
41
46
|
|
|
42
47
|
module.exports = {
|
|
43
48
|
createClient: (options) => _createClient(native, options),
|
|
49
|
+
createTableClient: (options) => _createTableClient(native, options),
|
|
44
50
|
StreamingIngestClient,
|
|
45
51
|
StreamingIngestChannel,
|
|
52
|
+
StreamingIngestElasticChannel,
|
|
46
53
|
StreamingIngestError,
|
|
47
54
|
StreamingIngestErrorCode,
|
|
48
55
|
ChannelStatus,
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Copyright 2026 Snowflake Inc.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
// you may not use this file except in compliance with the License.
|
|
6
|
+
// You may obtain a copy of the License at
|
|
7
|
+
//
|
|
8
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
//
|
|
10
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
// See the License for the specific language governing permissions and
|
|
14
|
+
// limitations under the License.
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
// Per-elastic-channel acknowledgement bridge. Port of Python's
|
|
19
|
+
// _mark_complete_callback.py and Java's MarkCompleteCallback. The Rust
|
|
20
|
+
// threadsafe-function callbacks fire into `markComplete` (one call per
|
|
21
|
+
// server-ack batch) and `invalidateAll` (once on channel teardown); the JS
|
|
22
|
+
// side owns the pending-promise map keyed by future id and resolves or
|
|
23
|
+
// rejects accordingly.
|
|
24
|
+
//
|
|
25
|
+
// Java/Python express the same protocol slightly differently: their
|
|
26
|
+
// markComplete *throws* on a missing future id or an odd-length range, and
|
|
27
|
+
// the host (JNI/PyO3) propagates that exception back to Rust which then
|
|
28
|
+
// invalidates the channel. The napi threadsafe-function bridge is
|
|
29
|
+
// NonBlocking and has no return channel, so we can't ride the exception
|
|
30
|
+
// back. Instead the JS side detects the inconsistency, rejects every
|
|
31
|
+
// in-flight promise, and calls `invalidateChannel` on the native handle
|
|
32
|
+
// directly — same end state, driven from JS.
|
|
33
|
+
|
|
34
|
+
const { StreamingIngestError } = require("./errors.js");
|
|
35
|
+
|
|
36
|
+
function buildError(success, errorCode, message, httpStatusCode, httpStatusName) {
|
|
37
|
+
if (success) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return new StreamingIngestError(
|
|
41
|
+
errorCode || "Fatal",
|
|
42
|
+
message || "Unknown error",
|
|
43
|
+
httpStatusCode != null ? Number(httpStatusCode) : 409,
|
|
44
|
+
httpStatusName || "Conflict",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Acknowledgement state for one elastic channel. Owns the pending-futures map
|
|
50
|
+
* shared with `StreamingIngestElasticChannel.appendRows`, the `invalidated`
|
|
51
|
+
* gate that mirrors Java/Python's flag, and a reference to the native channel
|
|
52
|
+
* for JS-driven invalidation. The native reference is plugged in after the
|
|
53
|
+
* channel is opened (chicken-and-egg: these callbacks are passed *into* the
|
|
54
|
+
* open call, so they exist before the native handle does).
|
|
55
|
+
*/
|
|
56
|
+
class ElasticChannelAckState {
|
|
57
|
+
constructor(pendingFutures) {
|
|
58
|
+
this._pendingFutures = pendingFutures;
|
|
59
|
+
this._invalidated = false;
|
|
60
|
+
this._nativeChannel = null;
|
|
61
|
+
this.markComplete = this._markComplete.bind(this);
|
|
62
|
+
this.invalidateAll = this._invalidateAll.bind(this);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Plug in the native channel after `getElasticChannel` resolves. */
|
|
66
|
+
setNativeChannel(nativeChannel) {
|
|
67
|
+
this._nativeChannel = nativeChannel;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_markComplete(payload) {
|
|
71
|
+
const { futureIdRanges, success, errorCode, message, httpStatusCode, httpStatusName } = payload;
|
|
72
|
+
|
|
73
|
+
if (futureIdRanges.length % 2 !== 0) {
|
|
74
|
+
const errMsg = `future_id_ranges has odd length ${futureIdRanges.length}, expected even (flattened [min, max] pairs)`;
|
|
75
|
+
this._invalidateLocally(
|
|
76
|
+
new StreamingIngestError("Fatal", errMsg, 500, "Internal Server Error"),
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const error = buildError(success, errorCode, message, httpStatusCode, httpStatusName);
|
|
82
|
+
|
|
83
|
+
const missingIds = [];
|
|
84
|
+
for (let i = 0; i < futureIdRanges.length; i += 2) {
|
|
85
|
+
const min = Number(futureIdRanges[i]);
|
|
86
|
+
const max = Number(futureIdRanges[i + 1]);
|
|
87
|
+
for (let fid = min; fid <= max; fid++) {
|
|
88
|
+
const pending = this._pendingFutures.get(fid);
|
|
89
|
+
if (pending === undefined) {
|
|
90
|
+
missingIds.push(fid);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
this._pendingFutures.delete(fid);
|
|
94
|
+
if (error) {
|
|
95
|
+
pending.reject(error);
|
|
96
|
+
} else {
|
|
97
|
+
pending.resolve();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// `_invalidated` gate matches Java/Python: after invalidateAll has run,
|
|
103
|
+
// the map is empty and further mark-complete callbacks for in-flight
|
|
104
|
+
// batches are expected to find nothing — don't escalate that to fatal.
|
|
105
|
+
if (missingIds.length > 0 && !this._invalidated) {
|
|
106
|
+
const errMsg = `Future ids [${missingIds.join(", ")}] not found in pending futures`;
|
|
107
|
+
this._invalidateLocally(
|
|
108
|
+
new StreamingIngestError("Fatal", errMsg, 500, "Internal Server Error"),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_invalidateAll(payload) {
|
|
114
|
+
this._invalidated = true;
|
|
115
|
+
const error =
|
|
116
|
+
buildError(
|
|
117
|
+
payload.success,
|
|
118
|
+
payload.errorCode,
|
|
119
|
+
payload.message,
|
|
120
|
+
payload.httpStatusCode,
|
|
121
|
+
payload.httpStatusName,
|
|
122
|
+
) || new StreamingIngestError("Fatal", "Elastic channel invalidated", 409, "Conflict");
|
|
123
|
+
for (const pending of this._pendingFutures.values()) {
|
|
124
|
+
pending.reject(error);
|
|
125
|
+
}
|
|
126
|
+
this._pendingFutures.clear();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* JS-driven invalidation when we detect state Rust isn't aware of yet
|
|
131
|
+
* (odd-length range, missing future id, duplicate future id). Idempotent
|
|
132
|
+
* via `_invalidated`. Promises are rejected first so the user observation
|
|
133
|
+
* doesn't depend on the FFI call succeeding; the native invalidate is
|
|
134
|
+
* best-effort — Rust will fire `invalidateAll` on its next informer
|
|
135
|
+
* tick, which finds an empty map and is a no-op.
|
|
136
|
+
*/
|
|
137
|
+
_invalidateLocally(error) {
|
|
138
|
+
if (this._invalidated) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this._invalidated = true;
|
|
142
|
+
for (const pending of this._pendingFutures.values()) {
|
|
143
|
+
pending.reject(error);
|
|
144
|
+
}
|
|
145
|
+
this._pendingFutures.clear();
|
|
146
|
+
if (this._nativeChannel) {
|
|
147
|
+
try {
|
|
148
|
+
this._nativeChannel.invalidateChannel(error.message);
|
|
149
|
+
} catch (invErr) {
|
|
150
|
+
// eslint-disable-next-line no-console
|
|
151
|
+
console.error("Elastic channel invalidate_channel FFI call failed:", invErr);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { ElasticChannelAckState };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Copyright 2026 Snowflake Inc.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
// you may not use this file except in compliance with the License.
|
|
6
|
+
// You may obtain a copy of the License at
|
|
7
|
+
//
|
|
8
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
//
|
|
10
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
// See the License for the specific language governing permissions and
|
|
14
|
+
// limitations under the License.
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
// Shared row serialization for regular and elastic channels. Kept in one place
|
|
19
|
+
// because the NDJSON byte-count behavior is a cross-language gotcha (Rust appends
|
|
20
|
+
// a trailing newline unconditionally) and the two channel classes must not drift.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert Buffer/Uint8Array values in a row according to binaryInputFormat.
|
|
24
|
+
* For HEX, binary values become hex-encoded strings. For UTF-8, decoded as UTF-8.
|
|
25
|
+
* For BASE64 (the default), base64-encoded for JSON serialization. Only top-level
|
|
26
|
+
* values are converted; nested structures are left as-is.
|
|
27
|
+
* @param {Object} row - Row object
|
|
28
|
+
* @param {string} binaryInputFormat - One of "BASE64", "HEX", "UTF-8"
|
|
29
|
+
* @returns {Object} Row with binary values converted
|
|
30
|
+
*/
|
|
31
|
+
function convertBytesInRow(row, binaryInputFormat) {
|
|
32
|
+
// Resolve the format-specific encoder once per row instead of branching per cell
|
|
33
|
+
// — appendRows is the hot path and rows are dominated by columns, not formats.
|
|
34
|
+
const encoding =
|
|
35
|
+
binaryInputFormat === "HEX" ? "hex" : binaryInputFormat === "UTF-8" ? "utf8" : "base64";
|
|
36
|
+
const result = {};
|
|
37
|
+
for (const [key, value] of Object.entries(row)) {
|
|
38
|
+
if (Buffer.isBuffer(value)) {
|
|
39
|
+
result[key] = value.toString(encoding);
|
|
40
|
+
} else if (value instanceof Uint8Array) {
|
|
41
|
+
result[key] = Buffer.from(value).toString(encoding);
|
|
42
|
+
} else {
|
|
43
|
+
result[key] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Serialize rows to an NDJSON Buffer, converting binary column values per binaryInputFormat.
|
|
51
|
+
* @param {Object[]} rows - Array of row objects
|
|
52
|
+
* @param {string} binaryInputFormat - One of "BASE64", "HEX", "UTF-8"
|
|
53
|
+
* @returns {Buffer} UTF-8 NDJSON buffer (no trailing newline; Rust appends one per row)
|
|
54
|
+
*/
|
|
55
|
+
function serializeRowsToNdjson(rows, binaryInputFormat) {
|
|
56
|
+
const ndjson = rows
|
|
57
|
+
.map((r) => JSON.stringify(convertBytesInRow(r, binaryInputFormat)))
|
|
58
|
+
.join("\n");
|
|
59
|
+
return Buffer.from(ndjson, "utf8");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { convertBytesInRow, serializeRowsToNdjson };
|