logstash-output-kusto 2.1.6-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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +42 -2
- data/lib/logstash/outputs/kusto/ingestor.rb +314 -13
- data/lib/logstash/outputs/kusto/streaming_chunker.rb +42 -0
- data/lib/logstash/outputs/kusto.rb +373 -41
- data/spec/outputs/kusto/ingestor_spec.rb +480 -0
- data/spec/outputs/kusto/streaming_chunker_spec.rb +57 -0
- data/spec/outputs/kusto_spec.rb +391 -0
- data/version +1 -1
- metadata +6 -3
- /data/vendor/jar-dependencies/org/logstash/outputs/logstash-output-kusto/{2.1.6/logstash-output-kusto-2.1.6.jar → 2.2.0/logstash-output-kusto-2.2.0.jar} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 907d3aff1700d1466d90df2a7a181022f43f70f4e50fa4e51f6517ca2c4824ff
|
|
4
|
+
data.tar.gz: 6f088a6cca015b0be48dbd1155a52914211ae00402e3d554b94873cb5c8f0ff4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
@logger.error(
|
|
137
|
-
|
|
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
|