logstash-output-kusto 2.1.5-java → 2.2.0-java

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d1bd0ebb330064eef1f7dc53bf1d5ec83d2aa2ec22ea08226f0c8d8f221b18f
4
- data.tar.gz: 9d1d6abbdb39d9400214080fd672f1c5ce7c51d03749502c703864a148e88d03
3
+ metadata.gz: 907d3aff1700d1466d90df2a7a181022f43f70f4e50fa4e51f6517ca2c4824ff
4
+ data.tar.gz: 6f088a6cca015b0be48dbd1155a52914211ae00402e3d554b94873cb5c8f0ff4
5
5
  SHA512:
6
- metadata.gz: 4863f2502b765cabce807d9ca2961da7f24f6a5f089e203ca303a07e2b7e069a873f5dc6d8410c35d79a10171af1f7b6ac68c7c907e105e219ff10d589f44b68
7
- data.tar.gz: b2fc6ec3e2437c821e2fba068ff816758cd1e966aa958d0cfe074f3cdf8195c2fdffae928563f965fb0f169f718c453a13c87c7e9d6539c5a5c7947d4baa0d0b
6
+ metadata.gz: 3d74753ae4b064d7808a535bb969aa77ce45a4319318af87c26a535bccac992f402de999ce23615501995da1894b651024e672055bb1b225b4267fcbec009266
7
+ data.tar.gz: d97fee3e3d9e0cf9f82f0575717c24fa258011666ed047b1239bf231da06e5dd0cecd4a5df0b67419185c993fc9c4885ce421624abbb2bc21e9a1bc5a94df4e8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ # 2.2.0
4
+
5
+ - Add opt-in streaming ingestion while preserving queued ingestion as the default.
6
+ - Bound streaming requests by encoded bytes without splitting events, and rely on the Kusto SDK for automatic queued fallback.
7
+ - Add atomic durable spooling, restart recovery, stable source identifiers, final-status quarantine, and streaming metrics.
8
+ - Add bounded worker backpressure, interruptible transient retry cycles, graceful shutdown draining, and proxy support.
9
+ - Secure spool ownership and permissions, reject unsafe paths and symlinks, and support Windows directory-fsync behavior.
10
+ - Preserve committed batches and safely handle missing spool files and completed batch-directory cleanup.
3
11
 
4
12
  # 2.0.3
5
13
 
data/README.md CHANGED
@@ -52,23 +52,62 @@ output {
52
52
  ```
53
53
  More information about configuring Logstash can be found in the [logstash configuration guide](https://www.elastic.co/guide/en/logstash/current/configuration.html)
54
54
 
55
+ ### Streaming ingestion
56
+
57
+ Queued ingestion remains the default and is recommended for high-throughput workloads. For lower ingestion latency, enable streaming mode:
58
+
59
+ ```ruby
60
+ output {
61
+ kusto {
62
+ ingestion_mode => "streaming"
63
+ streaming_max_request_bytes => 1048576
64
+ streaming_temp_directory => "/var/lib/logstash/kusto-streaming"
65
+ ingest_url => "https://ingest-<cluster-name>.kusto.windows.net/"
66
+ app_id => "<application id>"
67
+ app_key => "<application key/secret>"
68
+ app_tenant => "<tenant id>"
69
+ database => "<database name>"
70
+ table => "<target table>"
71
+ json_mapping => "<mapping name>"
72
+ }
73
+ }
74
+ ```
75
+
76
+ Streaming ingestion must be enabled on the target Kusto table. The connector groups encoded events into requests of at most 1 MiB by default, measured in bytes. It never splits one event: an event above the configured target is sent intact, and the Kusto client decides whether to stream it or fall back to queued ingestion.
77
+
78
+ Before acknowledging a Logstash batch, the connector writes all requests into an atomic local spool batch. Accepted requests are removed, transient failures apply backpressure and retry with interruptible exponential delays, and final non-success statuses are quarantined for investigation without replaying the entire request.
79
+
80
+ Streaming delivery is **at least once**. A process or host failure after Kusto accepts a request but before the local spool file is removed can cause that request to be recovered and ingested again. The stable source identifier is used for request tracking; it does not provide Kusto-side row deduplication.
81
+
82
+ The SDK's `Queued` result means the request was transferred to queued ingestion, not that ingestion into the table has completed. Continue monitoring Kusto ingestion failures for later mapping, schema, encoding-policy, or row-size failures.
83
+
84
+ The default spool is a destination-specific directory under Logstash `path.data`. Streaming mode defaults to private `0700` directories and `0600` files, rejects symlinked or untrusted recovery files, and allows only one active plugin instance per spool. When setting a custom directory, place it on persistent local storage with trusted parent directories. Group- or world-writable `dir_mode` and `file_mode` values are rejected.
85
+
55
86
  ### Available Configuration Keys
56
87
 
57
88
  | Parameter Name | Description | Notes |
58
89
  | --- | --- | --- |
59
- | **path** | The plugin writes events to temporary files before sending them to ADX. This parameter includes a path where files should be written and a time expression for file rotation to trigger an upload to the ADX service. The example above shows how to rotate the files every minute and check the Logstash docs for more information on time expressions. | Required
90
+ | **path** | The plugin writes events to temporary files before sending them to ADX. This parameter includes a path where files should be written and a time expression for file rotation to trigger an upload to the ADX service. The example above shows how to rotate the files every minute and check the Logstash docs for more information on time expressions. | Required for queued ingestion |
60
91
  | **ingest_url** | The Kusto endpoint for ingestion-related communication. See it on the Azure Portal.| Required|
61
92
  | **app_id, app_key, app_tenant**| Credentials required to connect to the ADX service. Be sure to use an application with 'ingest' privileges. | Optional|
62
93
  | **managed_identity**| Managed Identity to authenticate. For user-based managed ID, use the Client ID GUID. For system-based, use the value `system`. The ID needs to have 'ingest' privileges on the cluster. | Optional|
63
94
  | **database**| Database name to place events | Required |
64
95
  | **table** | Target table name to place events | Required
65
96
  | **json_mapping** | Maps each attribute from incoming event JSON strings to the appropriate column in the table. Note that this must be in JSON format, as this is the interface between Logstash and Kusto | Optional |
66
- | **recovery** | If set to true (default), plugin will attempt to resend pre-existing temp files found in the path upon startup | |
97
+ | **recovery** | If set to true (default), plugin will attempt to resend pre-existing temp files upon startup. With streaming, disabling recovery can leave committed spool files unprocessed after restart. | |
67
98
  | **delete_temp_files** | Determines if temp files will be deleted after a successful upload (true is default; set false for debug purposes only)| |
68
99
  | **flush_interval** | The time (in seconds) for flushing writes to temporary files. Default is 2 seconds, 0 will flush on every event. Increase this value to reduce IO calls but keep in mind that events in the buffer will be lost in case of abrupt failure.| |
100
+ | **dir_mode** | Directory permissions. Streaming defaults to `0700` and rejects group- or world-writable values. | |
101
+ | **file_mode** | Temporary-file permissions. Streaming defaults to `0600` and rejects group- or world-writable values. | |
69
102
  | **proxy_host** | The proxy hostname for redirecting traffic to Kusto.| |
70
103
  | **proxy_port** | The proxy port for the proxy. Defaults to 80.| |
71
104
  | **proxy_protocol** | The proxy server protocol , is one of http or https.| |
105
+ | **ingestion_mode** | `queued` for throughput-oriented ingestion or `streaming` for low-latency ingestion with automatic queued fallback. | Defaults to `queued` |
106
+ | **streaming_max_request_bytes** | Target maximum encoded bytes per streaming request. A single event is never split. | Defaults to 1048576 |
107
+ | **streaming_max_retry_attempts** | Transient retries before an interruptible cooldown cycle. During an outage, workers remain bounded and backpressure the Logstash pipeline instead of creating an unbounded retry queue. | Defaults to 2 |
108
+ | **streaming_retry_backoff_seconds** | Initial delay for connector retries; later retries use exponential backoff. | Defaults to 1 |
109
+ | **streaming_concurrent_requests** | Maximum streaming upload worker count. | Defaults to 4 |
110
+ | **streaming_temp_directory** | Durable local spool for streaming requests and restart recovery. Only one active output may use a directory. | Defaults below Logstash `path.data/plugins/logstash-output-kusto` |
72
111
 
73
112
  > Note : LS_JAVA_OPTS can be used to set proxy parameters as well (using export or SET options)
74
113
 
@@ -81,6 +120,7 @@ export LS_JAVA_OPTS="-Dhttp.proxyHost=1.2.34 -Dhttp.proxyPort=8989 -Dhttps.prox
81
120
 
82
121
  | Version | Release Date | Notes |
83
122
  | --- | --- | --- |
123
+ | 2.2.0 | 2026-07-16 | - Add opt-in Kusto streaming ingestion with byte-bounded requests, automatic queued fallback, durable restart recovery, bounded backpressure, secure local spooling, streaming metrics, and production stress coverage. Queued ingestion remains the default. |
84
124
  | 2.0.8 | 2024-10-23 | - Fix library deprecations, fix issues in the Azure Identity library |
85
125
  | 2.0.7 | 2024-01-01 | - Update Kusto JAVA SDK |
86
126
  | 2.0.3 | 2023-12-12 | - Make JSON mapping field optional. If not provided logstash output JSON attribute names will be used for column resolution |
@@ -11,6 +11,11 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
11
11
  class Ingestor
12
12
  require 'logstash-output-kusto_jars'
13
13
  RETRY_DELAY_SECONDS = 3
14
+ ACCEPTED_STREAMING_STATUSES = %w[Succeeded Queued Pending].freeze
15
+ FINAL_STREAMING_STATUSES = %w[Skipped PartiallySucceeded].freeze
16
+
17
+ class StreamingIngestionError < StandardError; end
18
+
14
19
  DEFAULT_THREADPOOL = Concurrent::ThreadPoolExecutor.new(
15
20
  min_threads: 1,
16
21
  max_threads: 8,
@@ -20,9 +25,17 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
20
25
  LOW_QUEUE_LENGTH = 3
21
26
  FIELD_REF = /%\{[^}]+\}/
22
27
 
23
- def initialize(ingest_url, app_id, app_key, app_tenant, managed_identity_id, cli_auth, database, table, json_mapping, delete_local, proxy_host , proxy_port , proxy_protocol,logger, threadpool = DEFAULT_THREADPOOL)
28
+ def initialize(ingest_url, app_id, app_key, app_tenant, managed_identity_id, cli_auth, database, table, json_mapping, delete_local, proxy_host , proxy_port , proxy_protocol,logger, threadpool = DEFAULT_THREADPOOL, ingestion_mode = 'queued', streaming_max_retry_attempts = 2, streaming_retry_backoff_seconds = 1, kusto_client = nil, sleeper = nil, streaming_metric = nil)
24
29
  @workers_pool = threadpool
25
30
  @logger = logger
31
+ @ingestion_mode = ingestion_mode
32
+ @streaming_max_retry_attempts = streaming_max_retry_attempts
33
+ @streaming_retry_backoff_seconds = streaming_retry_backoff_seconds
34
+ @sleeper = sleeper
35
+ @streaming_metric = streaming_metric
36
+ @retry_mutex = Mutex.new
37
+ @retry_condition = ConditionVariable.new
38
+ @stopping = false
26
39
  validate_config(database, table, json_mapping,proxy_protocol,app_id, app_key, managed_identity_id,cli_auth)
27
40
  @logger.info('Preparing Kusto resources.')
28
41
 
@@ -64,16 +77,37 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
64
77
  # kusto_connection_string.setClientVersionForTracing(name_for_tracing)
65
78
  version_for_tracing=Gem.loaded_specs['logstash-output-kusto']&.version || "unknown"
66
79
  kusto_connection_string.setConnectorDetails("Logstash",version_for_tracing.to_s,"","",false,"", java_util.Collections.emptyMap());
67
- @kusto_client = begin
68
- if is_direct_conn
69
- kusto_java.ingest.IngestClientFactory.createClient(kusto_connection_string)
80
+ @kusto_client = kusto_client || begin
81
+ http_client_properties = unless is_direct_conn
82
+ http_kusto = Java::com.microsoft.azure.kusto.data.http
83
+ java_net = Java::java.net
84
+ proxy_inet_server = java_net.InetSocketAddress.new(proxy_host, proxy_port)
85
+ proxy = Java::com.azure.core.http.ProxyOptions.new(
86
+ Java::com.azure.core.http.ProxyOptions::Type::HTTP,
87
+ proxy_inet_server
88
+ )
89
+ http_kusto.HttpClientProperties.builder().proxy(proxy).build()
90
+ end
91
+
92
+ if @ingestion_mode == 'streaming'
93
+ if http_client_properties
94
+ kusto_java.ingest.IngestClientFactory.createManagedStreamingIngestClient(
95
+ kusto_connection_string,
96
+ http_client_properties,
97
+ true
98
+ )
99
+ else
100
+ kusto_java.ingest.IngestClientFactory.createManagedStreamingIngestClient(
101
+ kusto_connection_string
102
+ )
103
+ end
104
+ elsif http_client_properties
105
+ kusto_java.ingest.IngestClientFactory.createClient(
106
+ kusto_connection_string,
107
+ http_client_properties
108
+ )
70
109
  else
71
- http_kusto = Java::com.microsoft.azure.kusto.data.http
72
- java_net = Java::java.net
73
- proxy_inet_server = java_net.InetSocketAddress.new(proxy_host, proxy_port)
74
- proxy = Java::com.azure.core.http.ProxyOptions.new(Java::com.azure.core.http.ProxyOptions::Type::HTTP, proxy_inet_server)
75
- http_client_properties = http_kusto.HttpClientProperties.builder().proxy(proxy).build()
76
- kusto_java.ingest.IngestClientFactory.createClient(kusto_connection_string, http_client_properties)
110
+ kusto_java.ingest.IngestClientFactory.createClient(kusto_connection_string)
77
111
  end
78
112
  end
79
113
 
@@ -132,12 +166,36 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
132
166
  LogStash::Util.set_thread_name("Kusto to ingest file: #{path}")
133
167
  upload(path, delete_on_success)
134
168
  end
135
- rescue Exception => e
136
- @logger.error('StandardError.', exception: e.class, message: e.message, path: path, backtrace: e.backtrace)
137
- raise e
169
+ rescue => e
170
+ @logger.error(
171
+ 'Failed to enqueue Kusto ingestion.',
172
+ exception: e.class,
173
+ message: e.message,
174
+ path: path,
175
+ backtrace: e.backtrace
176
+ )
177
+ if @ingestion_mode == 'streaming'
178
+ return nil if stopping?
179
+
180
+ @logger.warn(
181
+ 'Streaming executor rejected a request; ingesting synchronously for backpressure.',
182
+ path: path
183
+ )
184
+ return upload(path, delete_on_success)
185
+ end
186
+
187
+ raise
138
188
  end
139
189
 
140
190
  def upload(path, delete_on_success)
191
+ return upload_streaming(path, delete_on_success) if
192
+ @ingestion_mode == 'streaming'
193
+
194
+ upload_queued(path, delete_on_success)
195
+ end
196
+
197
+ private
198
+ def upload_queued(path, delete_on_success)
141
199
  file_size = File.size(path)
142
200
  @logger.debug("Sending file to kusto: #{path}. size: #{file_size}")
143
201
 
@@ -177,9 +235,252 @@ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
177
235
  retry
178
236
  end
179
237
 
238
+ private
239
+ def upload_streaming(path, delete_on_success)
240
+ attempts = 0
241
+
242
+ begin
243
+ file_size = File.size(path)
244
+ if file_size <= 0
245
+ @logger.warn("File #{path} is an empty file and is not ingested.")
246
+ File.delete(path) if delete_on_success
247
+ return
248
+ end
249
+
250
+ source_id = source_id_for(path)
251
+ file_source_info =
252
+ Java::com.microsoft.azure.kusto.ingest.source.FileSourceInfo.new(path, source_id)
253
+ result = @kusto_client.ingestFromFile(file_source_info, @ingestion_properties)
254
+ status = validate_streaming_result(result, file_size)
255
+ if FINAL_STREAMING_STATUSES.include?(status)
256
+ quarantine_streaming_file(path, status)
257
+ else
258
+ complete_streaming_file(path, delete_on_success)
259
+ end
260
+ increment_streaming_status(status)
261
+ status
262
+ rescue Java::ComMicrosoftAzureKustoIngestExceptions::IngestionClientException => e
263
+ retain_failed_streaming_file(path, e, 'Streaming request failed permanently.')
264
+ rescue Java::ComMicrosoftAzureKustoIngestExceptions::IngestionServiceException => e
265
+ if permanent_service_error?(e)
266
+ retain_failed_streaming_file(path, e, 'Streaming request failed permanently.')
267
+ return
268
+ end
269
+
270
+ if attempts >= @streaming_max_retry_attempts
271
+ delay = @streaming_retry_backoff_seconds * (2**attempts)
272
+ @streaming_metric.increment(:retry_cycles) unless @streaming_metric.nil?
273
+ @logger.warn(
274
+ 'Streaming retry cycle was exhausted; applying backpressure before retrying.',
275
+ path: path,
276
+ retry_delay_seconds: delay,
277
+ exception: e.class,
278
+ message: e.message
279
+ )
280
+ attempts = 0
281
+ return unless wait_for_retry(delay)
282
+
283
+ retry
284
+ end
285
+
286
+ delay = @streaming_retry_backoff_seconds * (2**attempts)
287
+ attempts += 1
288
+ @logger.warn(
289
+ 'Streaming request failed transiently, retrying.',
290
+ attempt: attempts,
291
+ max_retry_attempts: @streaming_max_retry_attempts,
292
+ retry_delay_seconds: delay,
293
+ exception: e.class,
294
+ message: e.message
295
+ )
296
+ return unless wait_for_retry(delay)
297
+
298
+ retry
299
+ rescue StreamingIngestionError => e
300
+ retain_failed_streaming_file(path, e, 'Streaming returned an unsuccessful status.')
301
+ rescue Errno::ENOENT, Java::JavaNioFile::NoSuchFileException => e
302
+ @logger.error(
303
+ "File doesn't exist! Unrecoverable error.",
304
+ exception: e.class,
305
+ message: e.message,
306
+ path: path,
307
+ backtrace: e.backtrace
308
+ )
309
+ rescue => e
310
+ retain_failed_streaming_file(path, e, 'Streaming request failed unexpectedly.')
311
+ end
312
+ end
313
+
314
+ private
315
+ def validate_streaming_result(result, file_size)
316
+ statuses = result.getIngestionStatusCollection
317
+ if statuses.nil? || statuses.empty?
318
+ raise StreamingIngestionError, 'Streaming ingestion returned no status.'
319
+ end
320
+
321
+ ingestion_statuses = statuses.to_a
322
+ status_names = ingestion_statuses.map { |ingestion_status| ingestion_status.status.to_s }
323
+ unique_statuses = status_names.uniq
324
+ if unique_statuses.length > 1
325
+ @logger.warn(
326
+ 'Streaming request returned mixed statuses and will be quarantined.',
327
+ statuses: unique_statuses,
328
+ bytes: file_size
329
+ )
330
+ return 'PartiallySucceeded'
331
+ end
332
+
333
+ ingestion_status = ingestion_statuses.first
334
+ status = unique_statuses.first
335
+ if FINAL_STREAMING_STATUSES.include?(status)
336
+ @logger.warn(
337
+ 'Streaming request reached a final non-success status and will not be retried.',
338
+ status: status,
339
+ details: ingestion_status.respond_to?(:details) ? ingestion_status.details : nil,
340
+ error_code: ingestion_status.respond_to?(:errorCode) ? ingestion_status.errorCode : nil,
341
+ bytes: file_size
342
+ )
343
+ return status
344
+ end
345
+
346
+ unless ACCEPTED_STREAMING_STATUSES.include?(status)
347
+ details = ingestion_status.respond_to?(:details) ? ingestion_status.details : nil
348
+ raise StreamingIngestionError,
349
+ "Streaming ingestion returned #{status}. #{details}".strip
350
+ end
351
+
352
+ @logger.debug(
353
+ "Streaming request accepted. status=#{status} bytes=#{file_size}",
354
+ status: status,
355
+ bytes: file_size,
356
+ database: @ingestion_properties.getDatabaseName,
357
+ table: @ingestion_properties.getTableName
358
+ )
359
+ status
360
+ end
361
+
362
+ private
363
+ def increment_streaming_status(status)
364
+ return if @streaming_metric.nil?
365
+
366
+ case status
367
+ when 'Succeeded'
368
+ @streaming_metric.increment(:streamed)
369
+ when 'Queued'
370
+ @streaming_metric.increment(:queued_fallback)
371
+ when 'Pending'
372
+ @streaming_metric.increment(:pending)
373
+ else
374
+ @streaming_metric.increment(:final_non_success)
375
+ end
376
+ end
377
+
378
+ private
379
+ def retain_failed_streaming_file(path, exception, message)
380
+ @streaming_metric.increment(:failures) unless @streaming_metric.nil?
381
+ @logger.error(
382
+ message,
383
+ exception: exception.class,
384
+ message: exception.message,
385
+ path: path,
386
+ backtrace: exception.backtrace
387
+ )
388
+ nil
389
+ end
390
+
391
+ private
392
+ def source_id_for(path)
393
+ source_id = File.basename(path)[
394
+ /stream-\d+-(?<source_id>[0-9a-fA-F-]{36})\.json\z/,
395
+ :source_id
396
+ ]
397
+ return Java::java.util.UUID.fromString(source_id) unless source_id.nil?
398
+
399
+ Java::java.util.UUID.nameUUIDFromBytes(File.basename(path).to_java_bytes)
400
+ end
401
+
402
+ private
403
+ def complete_streaming_file(path, delete_on_success)
404
+ if delete_on_success
405
+ File.delete(path)
406
+ else
407
+ File.rename(path, "#{path}.completed")
408
+ end
409
+
410
+ parent = File.dirname(path)
411
+ fsync_directory(parent)
412
+ return unless File.basename(parent).match?(/\Abatch-.*\.ready\z/)
413
+
414
+ Dir.rmdir(parent)
415
+ fsync_directory(File.dirname(parent))
416
+ rescue Errno::ENOTEMPTY, Errno::ENOENT
417
+ nil
418
+ end
419
+
420
+ private
421
+ def quarantine_streaming_file(path, status)
422
+ quarantine_path = "#{path}.#{status.downcase}"
423
+ File.rename(path, quarantine_path)
424
+ fsync_directory(File.dirname(path))
425
+ @logger.error(
426
+ 'Streaming request was quarantined after a final non-success status.',
427
+ status: status,
428
+ path: quarantine_path
429
+ )
430
+ end
431
+
432
+ private
433
+ def fsync_directory(directory)
434
+ return if Gem.win_platform?
435
+
436
+ File.open(directory, File::RDONLY) { |file| file.fsync }
437
+ rescue SystemCallError => e
438
+ unsupported_errors = [Errno::EINVAL::Errno]
439
+ unsupported_errors << Errno::EISDIR::Errno if defined?(Errno::EISDIR)
440
+ unsupported_errors << Errno::ENOTSUP::Errno if defined?(Errno::ENOTSUP)
441
+ raise unless unsupported_errors.include?(e.errno)
442
+
443
+ @logger.debug('Directory fsync is not supported on this platform.', path: directory)
444
+ end
445
+
446
+ private
447
+ def wait_for_retry(delay)
448
+ if @sleeper
449
+ @sleeper.call(delay)
450
+ return !stopping?
451
+ end
452
+
453
+ @retry_mutex.synchronize do
454
+ @retry_condition.wait(@retry_mutex, delay) unless @stopping
455
+ !@stopping
456
+ end
457
+ end
458
+
459
+ private
460
+ def stopping?
461
+ @retry_mutex.synchronize { @stopping }
462
+ end
463
+
464
+ private
465
+ def permanent_service_error?(exception)
466
+ current = exception
467
+ while current
468
+ return current.isPermanent if current.respond_to?(:isPermanent)
469
+
470
+ current = current.respond_to?(:cause) ? current.cause : nil
471
+ end
472
+ false
473
+ end
474
+
475
+ public
180
476
  def stop
477
+ @retry_mutex.synchronize do
478
+ @stopping = true
479
+ @retry_condition.broadcast
480
+ end
181
481
  @workers_pool.shutdown
182
482
  @workers_pool.wait_for_termination(nil) # block until its done
483
+ @kusto_client.close
183
484
  end
184
485
  end
185
486
  end
@@ -0,0 +1,42 @@
1
+ # encoding: utf-8
2
+
3
+ require 'logstash/outputs/base'
4
+ require 'logstash/namespace'
5
+
6
+ class LogStash::Outputs::Kusto < LogStash::Outputs::Base
7
+ class StreamingChunker
8
+ def initialize(max_bytes)
9
+ raise ArgumentError, 'max_bytes must be greater than zero' unless max_bytes.positive?
10
+
11
+ @max_bytes = max_bytes
12
+ end
13
+
14
+ def chunks(encoded_events)
15
+ chunks = []
16
+ current_chunk = []
17
+ current_bytes = 0
18
+
19
+ encoded_events.each do |encoded|
20
+ encoded_bytes = encoded.bytesize
21
+
22
+ if !current_chunk.empty? && current_bytes + encoded_bytes > @max_bytes
23
+ chunks << current_chunk
24
+ current_chunk = []
25
+ current_bytes = 0
26
+ end
27
+
28
+ current_chunk << encoded
29
+ current_bytes += encoded_bytes
30
+
31
+ if encoded_bytes > @max_bytes
32
+ chunks << current_chunk
33
+ current_chunk = []
34
+ current_bytes = 0
35
+ end
36
+ end
37
+
38
+ chunks << current_chunk unless current_chunk.empty?
39
+ chunks
40
+ end
41
+ end
42
+ end