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.
@@ -2,6 +2,7 @@
2
2
  require_relative "../../spec_helpers.rb"
3
3
  require 'logstash/outputs/kusto'
4
4
  require 'logstash/outputs/kusto/ingestor'
5
+ require 'tempfile'
5
6
 
6
7
  describe LogStash::Outputs::Kusto::Ingestor do
7
8
 
@@ -86,6 +87,485 @@ describe LogStash::Outputs::Kusto::Ingestor do
86
87
 
87
88
  end
88
89
 
90
+ describe 'vendored streaming fallback policy' do
91
+ it 'queues uncompressed JSON above the SDK estimate and hard size boundaries' do
92
+ policy = Java::com.microsoft.azure.kusto.ingest.ManagedStreamingQueuingPolicy::Default
93
+ json_format =
94
+ Java::com.microsoft.azure.kusto.ingest.IngestionProperties::DataFormat::JSON
95
+ six_mib = 6 * 1024 * 1024
96
+ ten_mib = 10 * 1024 * 1024
97
+
98
+ expect(policy.shouldUseQueuedIngestion(six_mib, false, json_format)).to be(false)
99
+ expect(policy.shouldUseQueuedIngestion(six_mib + 1, false, json_format)).to be(true)
100
+ expect(policy.shouldUseQueuedIngestion(ten_mib, true, json_format)).to be(true)
101
+ expect(policy.shouldUseQueuedIngestion(ten_mib + 1, false, json_format)).to be(true)
102
+ end
103
+ end
104
+
105
+ describe '#upload with streaming' do
106
+ let(:streaming_client) { double('streaming client') }
107
+ let(:threadpool) { double('threadpool', shutdown: nil, wait_for_termination: nil) }
108
+ let(:sleeper) { double('sleeper', call: nil) }
109
+ let(:streaming_metric) { double('streaming metric', increment: nil) }
110
+ let(:ingestor) do
111
+ described_class.new(
112
+ ingest_url,
113
+ app_id,
114
+ app_key,
115
+ app_tenant,
116
+ managed_identity,
117
+ cliauth,
118
+ database,
119
+ table,
120
+ json_mapping,
121
+ delete_local,
122
+ proxy_host,
123
+ proxy_port,
124
+ proxy_protocol,
125
+ logger,
126
+ threadpool,
127
+ 'streaming',
128
+ 2,
129
+ 0.01,
130
+ streaming_client,
131
+ sleeper,
132
+ streaming_metric
133
+ )
134
+ end
135
+
136
+ def with_streaming_file(payload = "{\"id\":1}\n")
137
+ file = Tempfile.new(['kusto-streaming', '.json'])
138
+ file.binmode
139
+ file.write(payload)
140
+ file.close
141
+ yield file.path
142
+ ensure
143
+ file&.unlink
144
+ end
145
+
146
+ def ingestion_result(status)
147
+ double(
148
+ 'ingestion result',
149
+ getIngestionStatusCollection: [
150
+ double('ingestion status', status: status, details: nil, errorCode: nil)
151
+ ]
152
+ )
153
+ end
154
+
155
+ %w[Succeeded Queued Pending].each do |status|
156
+ it "accepts #{status} and deletes the completed spool file" do
157
+ allow(streaming_client).to receive(:ingestFromFile).and_return(ingestion_result(status))
158
+
159
+ with_streaming_file do |path|
160
+ expect(ingestor.upload(path, true)).to eq(status)
161
+ expect(File.exist?(path)).to be(false)
162
+ end
163
+ end
164
+ end
165
+
166
+ %w[Skipped PartiallySucceeded].each do |status|
167
+ it "quarantines final #{status} without replaying the whole file" do
168
+ allow(streaming_client).to receive(:ingestFromFile).and_return(ingestion_result(status))
169
+
170
+ with_streaming_file do |path|
171
+ expect(ingestor.upload(path, true)).to eq(status)
172
+ expect(File.exist?(path)).to be(false)
173
+ quarantine_path = "#{path}.#{status.downcase}"
174
+ expect(File.exist?(quarantine_path)).to be(true)
175
+ File.delete(quarantine_path)
176
+ end
177
+ expect(logger).to have_received(:warn)
178
+ expect(logger).to have_received(:error).with(
179
+ 'Streaming request was quarantined after a final non-success status.',
180
+ hash_including(status: status)
181
+ )
182
+ expect(streaming_client).to have_received(:ingestFromFile).once
183
+ end
184
+ end
185
+
186
+ it 'retains the spool file when Kusto returns Failed' do
187
+ allow(streaming_client).to receive(:ingestFromFile).and_return(ingestion_result('Failed'))
188
+
189
+ with_streaming_file do |path|
190
+ expect(ingestor.upload(path, true)).to be_nil
191
+ expect(File.exist?(path)).to be(true)
192
+ end
193
+ expect(streaming_metric).to have_received(:increment).with(:failures)
194
+ end
195
+
196
+ it 'retains the spool file when the SDK returns no status' do
197
+ result = double('ingestion result', getIngestionStatusCollection: [])
198
+ allow(streaming_client).to receive(:ingestFromFile).and_return(result)
199
+
200
+ with_streaming_file do |path|
201
+ expect(ingestor.upload(path, true)).to be_nil
202
+ expect(File.exist?(path)).to be(true)
203
+ end
204
+ end
205
+
206
+ it 'quarantines a mixed status collection instead of trusting the first status' do
207
+ result = double(
208
+ 'ingestion result',
209
+ getIngestionStatusCollection: [
210
+ double('succeeded status', status: 'Succeeded'),
211
+ double('failed status', status: 'Failed')
212
+ ]
213
+ )
214
+ allow(streaming_client).to receive(:ingestFromFile).and_return(result)
215
+
216
+ with_streaming_file do |path|
217
+ expect(ingestor.upload(path, true)).to eq('PartiallySucceeded')
218
+ quarantine_path = "#{path}.partiallysucceeded"
219
+ expect(File.exist?(quarantine_path)).to be(true)
220
+ File.delete(quarantine_path)
221
+ end
222
+ end
223
+
224
+ it 'passes the complete spool file and a source id to the SDK' do
225
+ payload = "{\"id\":1}\n{\"id\":2}\n"
226
+ captured_path = nil
227
+ captured_source_id = nil
228
+ allow(streaming_client).to receive(:ingestFromFile) do |source, _properties|
229
+ captured_path = source.getFilePath
230
+ captured_source_id = source.getSourceId
231
+ ingestion_result('Succeeded')
232
+ end
233
+
234
+ with_streaming_file(payload) do |path|
235
+ ingestor.upload(path, false)
236
+ expect(captured_path).to eq(path)
237
+ end
238
+ expect(captured_source_id).not_to be_nil
239
+ end
240
+
241
+ it 'reuses the source id when a spool file is recovered' do
242
+ source_ids = []
243
+ results = [ingestion_result('Failed'), ingestion_result('Succeeded')]
244
+ allow(streaming_client).to receive(:ingestFromFile) do |source, _properties|
245
+ source_ids << source.getSourceId.to_s
246
+ results.shift
247
+ end
248
+
249
+ with_streaming_file do |path|
250
+ ingestor.upload(path, true)
251
+ ingestor.upload(path, false)
252
+ File.delete("#{path}.completed")
253
+ end
254
+
255
+ expect(source_ids.uniq.length).to eq(1)
256
+ end
257
+
258
+ it 'marks accepted files completed when debug retention is enabled' do
259
+ allow(streaming_client).to receive(:ingestFromFile).and_return(ingestion_result('Succeeded'))
260
+
261
+ with_streaming_file do |path|
262
+ expect(ingestor.upload(path, false)).to eq('Succeeded')
263
+ expect(File.exist?(path)).to be(false)
264
+ expect(File.exist?("#{path}.completed")).to be(true)
265
+ File.delete("#{path}.completed")
266
+ end
267
+ end
268
+
269
+ it 'removes a committed batch directory after its final spool file completes' do
270
+ allow(streaming_client).to receive(:ingestFromFile).and_return(ingestion_result('Succeeded'))
271
+
272
+ Dir.mktmpdir('kusto-streaming-batch') do |directory|
273
+ batch_directory = File.join(directory, 'batch-completed.ready')
274
+ Dir.mkdir(batch_directory)
275
+ first_path = File.join(
276
+ batch_directory,
277
+ 'stream-000000-11111111-1111-1111-1111-111111111111.json'
278
+ )
279
+ second_path = File.join(
280
+ batch_directory,
281
+ 'stream-000001-22222222-2222-2222-2222-222222222222.json'
282
+ )
283
+ File.write(first_path, "{\"id\":1}\n")
284
+ File.write(second_path, "{\"id\":2}\n")
285
+
286
+ expect(ingestor.upload(first_path, true)).to eq('Succeeded')
287
+ expect(Dir.exist?(batch_directory)).to be(true)
288
+ expect(ingestor.upload(second_path, true)).to eq('Succeeded')
289
+ expect(Dir.exist?(batch_directory)).to be(false)
290
+ end
291
+ end
292
+
293
+ it 'retries transient service failures with bounded backoff and a stable source id' do
294
+ transient_error =
295
+ Java::com.microsoft.azure.kusto.ingest.exceptions.IngestionServiceException.new('transient')
296
+ attempts = 0
297
+ source_ids = []
298
+ allow(streaming_client).to receive(:ingestFromFile) do |source, _properties|
299
+ attempts += 1
300
+ source_ids << source.getSourceId.to_s
301
+ raise transient_error if attempts <= 2
302
+
303
+ ingestion_result('Succeeded')
304
+ end
305
+
306
+ with_streaming_file do |path|
307
+ expect(ingestor.upload(path, true)).to eq('Succeeded')
308
+ end
309
+ expect(sleeper).to have_received(:call).with(0.01).ordered
310
+ expect(sleeper).to have_received(:call).with(0.02).ordered
311
+ expect(source_ids.uniq.length).to eq(1)
312
+ end
313
+
314
+ it 'does not retry client failures and retains the spool file' do
315
+ client_error =
316
+ Java::com.microsoft.azure.kusto.ingest.exceptions.IngestionClientException.new('permanent')
317
+ allow(streaming_client).to receive(:ingestFromFile).and_raise(client_error)
318
+
319
+ with_streaming_file do |path|
320
+ expect(ingestor.upload(path, true)).to be_nil
321
+ expect(File.exist?(path)).to be(true)
322
+ end
323
+ expect(streaming_client).to have_received(:ingestFromFile).once
324
+ expect(sleeper).not_to have_received(:call)
325
+ end
326
+
327
+ it 'does not retry permanent service failures and retains the spool file' do
328
+ data_error =
329
+ Java::com.microsoft.azure.kusto.data.exceptions.DataServiceException.new(
330
+ 'activity-id',
331
+ 'permanent',
332
+ true
333
+ )
334
+ service_error =
335
+ Java::com.microsoft.azure.kusto.ingest.exceptions.IngestionServiceException.new(
336
+ 'permanent',
337
+ data_error
338
+ )
339
+ allow(streaming_client).to receive(:ingestFromFile).and_raise(service_error)
340
+
341
+ with_streaming_file do |path|
342
+ expect(ingestor.upload(path, true)).to be_nil
343
+ expect(File.exist?(path)).to be(true)
344
+ end
345
+ expect(streaming_client).to have_received(:ingestFromFile).once
346
+ expect(sleeper).not_to have_received(:call)
347
+ end
348
+
349
+ it 'applies backpressure and starts another retry cycle after transient retries are exhausted' do
350
+ transient_error =
351
+ Java::com.microsoft.azure.kusto.ingest.exceptions.IngestionServiceException.new('transient')
352
+ attempts = 0
353
+ source_ids = []
354
+ allow(streaming_client).to receive(:ingestFromFile) do |source, _properties|
355
+ attempts += 1
356
+ source_ids << source.getSourceId.to_s
357
+ raise transient_error if attempts <= 3
358
+
359
+ ingestion_result('Succeeded')
360
+ end
361
+
362
+ with_streaming_file do |path|
363
+ expect(ingestor.upload(path, true)).to eq('Succeeded')
364
+ expect(File.exist?(path)).to be(false)
365
+ end
366
+ expect(streaming_client).to have_received(:ingestFromFile).exactly(4).times
367
+ expect(sleeper).to have_received(:call).with(0.01).once
368
+ expect(sleeper).to have_received(:call).with(0.02).once
369
+ expect(sleeper).to have_received(:call).with(0.04).once
370
+ expect(streaming_metric).to have_received(:increment).with(:retry_cycles)
371
+ expect(source_ids.uniq.length).to eq(1)
372
+ end
373
+
374
+ it 'retains the spool file for unexpected SDK failures' do
375
+ allow(streaming_client).to receive(:ingestFromFile).and_raise(StandardError, 'unexpected')
376
+
377
+ with_streaming_file do |path|
378
+ expect(ingestor.upload(path, true)).to be_nil
379
+ expect(File.exist?(path)).to be(true)
380
+ end
381
+ expect(streaming_metric).to have_received(:increment).with(:failures)
382
+ end
383
+
384
+ it 'logs a missing spool file without allowing the error to escape the upload task' do
385
+ file = Tempfile.new(['missing-kusto-streaming', '.json'])
386
+ path = file.path
387
+ file.close!
388
+
389
+ expect(streaming_client).not_to receive(:ingestFromFile)
390
+ expect { ingestor.upload(path, true) }.not_to raise_error
391
+ expect(logger).to have_received(:error).with(
392
+ "File doesn't exist! Unrecoverable error.",
393
+ hash_including(exception: Errno::ENOENT, path: path)
394
+ )
395
+ end
396
+
397
+ it 'ingests synchronously when the streaming executor rejects a committed spool file' do
398
+ allow(threadpool).to receive(:remaining_capacity).and_return(10)
399
+ allow(threadpool).to receive(:post).and_raise(Concurrent::RejectedExecutionError)
400
+ allow(streaming_client).to receive(:ingestFromFile).and_return(ingestion_result('Succeeded'))
401
+
402
+ with_streaming_file do |path|
403
+ expect(ingestor.upload_async(path, true)).to eq('Succeeded')
404
+ expect(File.exist?(path)).to be(false)
405
+ end
406
+ expect(logger).to have_received(:error).with(
407
+ 'Failed to enqueue Kusto ingestion.',
408
+ hash_including(path: kind_of(String))
409
+ )
410
+ expect(logger).to have_received(:warn).with(
411
+ 'Streaming executor rejected a request; ingesting synchronously for backpressure.',
412
+ hash_including(path: kind_of(String))
413
+ )
414
+ end
415
+
416
+ it 'preserves queued-mode enqueue failure behavior' do
417
+ allow(threadpool).to receive(:remaining_capacity).and_return(10)
418
+ allow(threadpool).to receive(:post).and_raise(Concurrent::RejectedExecutionError)
419
+ queued_ingestor = described_class.new(
420
+ ingest_url,
421
+ app_id,
422
+ app_key,
423
+ app_tenant,
424
+ managed_identity,
425
+ cliauth,
426
+ database,
427
+ table,
428
+ json_mapping,
429
+ delete_local,
430
+ proxy_host,
431
+ proxy_port,
432
+ proxy_protocol,
433
+ logger,
434
+ threadpool,
435
+ 'queued',
436
+ 2,
437
+ 1,
438
+ streaming_client
439
+ )
440
+
441
+ expect do
442
+ queued_ingestor.upload_async('/queued/request.json', true)
443
+ end.to raise_error(Concurrent::RejectedExecutionError)
444
+ end
445
+
446
+ it 'drains the worker pool and closes the streaming client on stop' do
447
+ allow(streaming_client).to receive(:close)
448
+
449
+ ingestor.stop
450
+
451
+ expect(streaming_client).to have_received(:close)
452
+ expect(threadpool).to have_received(:shutdown)
453
+ expect(threadpool).to have_received(:wait_for_termination).with(nil)
454
+ end
455
+
456
+ it 'interrupts an in-flight retry backoff during shutdown' do
457
+ transient_error =
458
+ Java::com.microsoft.azure.kusto.ingest.exceptions.IngestionServiceException.new('transient')
459
+ attempted = Queue.new
460
+ allow(streaming_client).to receive(:ingestFromFile) do
461
+ attempted << true
462
+ raise transient_error
463
+ end
464
+ allow(streaming_client).to receive(:close)
465
+ interruptible = described_class.new(
466
+ ingest_url,
467
+ app_id,
468
+ app_key,
469
+ app_tenant,
470
+ managed_identity,
471
+ cliauth,
472
+ database,
473
+ table,
474
+ json_mapping,
475
+ delete_local,
476
+ proxy_host,
477
+ proxy_port,
478
+ proxy_protocol,
479
+ logger,
480
+ threadpool,
481
+ 'streaming',
482
+ 2,
483
+ 30,
484
+ streaming_client,
485
+ nil,
486
+ streaming_metric
487
+ )
488
+
489
+ with_streaming_file do |path|
490
+ upload_thread = Thread.new { interruptible.upload(path, true) }
491
+ attempted.pop
492
+ interruptible.stop
493
+
494
+ expect(upload_thread.join(1)).to eq(upload_thread)
495
+ expect(File.exist?(path)).to be(true)
496
+ end
497
+ end
498
+
499
+ it 'skips completion directory fsync on Windows' do
500
+ allocated = described_class.allocate
501
+ allow(Gem).to receive(:win_platform?).and_return(true)
502
+ expect(File).not_to receive(:open)
503
+
504
+ expect { allocated.send(:fsync_directory, 'C:/spool') }.not_to raise_error
505
+ end
506
+ end
507
+
508
+ describe 'streaming client creation' do
509
+ it 'uses the SDK streaming factory and lets the SDK derive both endpoints' do
510
+ factory = Java::com.microsoft.azure.kusto.ingest.IngestClientFactory
511
+ client = double('managed client', close: nil)
512
+ allow(factory).to receive(:createManagedStreamingIngestClient).and_return(client)
513
+ threadpool = double('threadpool', shutdown: nil, wait_for_termination: nil)
514
+
515
+ ingestor = described_class.new(
516
+ ingest_url,
517
+ app_id,
518
+ app_key,
519
+ app_tenant,
520
+ managed_identity,
521
+ cliauth,
522
+ database,
523
+ table,
524
+ json_mapping,
525
+ delete_local,
526
+ nil,
527
+ proxy_port,
528
+ proxy_protocol,
529
+ logger,
530
+ threadpool,
531
+ 'streaming'
532
+ )
533
+ ingestor.stop
534
+
535
+ expect(factory).to have_received(:createManagedStreamingIngestClient).once
536
+ end
537
+
538
+ it 'enables endpoint correction when creating a managed client with a proxy' do
539
+ factory = Java::com.microsoft.azure.kusto.ingest.IngestClientFactory
540
+ client = double('managed client', close: nil)
541
+ allow(factory).to receive(:createManagedStreamingIngestClient).and_return(client)
542
+ threadpool = double('threadpool', shutdown: nil, wait_for_termination: nil)
543
+
544
+ ingestor = described_class.new(
545
+ ingest_url,
546
+ app_id,
547
+ app_key,
548
+ app_tenant,
549
+ managed_identity,
550
+ cliauth,
551
+ database,
552
+ table,
553
+ json_mapping,
554
+ delete_local,
555
+ proxy_host,
556
+ proxy_port,
557
+ proxy_protocol,
558
+ logger,
559
+ threadpool,
560
+ 'streaming'
561
+ )
562
+ ingestor.stop
563
+
564
+ expect(factory).to have_received(:createManagedStreamingIngestClient)
565
+ .with(anything, anything, true)
566
+ end
567
+ end
568
+
89
569
  # describe 'receiving events' do
90
570
 
91
571
  # context 'with non-zero flush interval' do
@@ -0,0 +1,57 @@
1
+ # encoding: utf-8
2
+
3
+ require_relative '../../spec_helpers'
4
+ require 'logstash/outputs/kusto/streaming_chunker'
5
+
6
+ describe LogStash::Outputs::Kusto::StreamingChunker do
7
+ describe '#chunks' do
8
+ it 'returns no chunks for an empty batch' do
9
+ expect(described_class.new(10).chunks([])).to eq([])
10
+ end
11
+
12
+ it 'combines events while their encoded bytes fit within the limit' do
13
+ chunks = described_class.new(10).chunks(%w[1234 567 89])
14
+
15
+ expect(chunks).to eq([%w[1234 567 89]])
16
+ end
17
+
18
+ it 'starts a new chunk before the next event would exceed the limit' do
19
+ chunks = described_class.new(6).chunks(%w[1234 567 89])
20
+
21
+ expect(chunks).to eq([%w[1234], %w[567 89]])
22
+ end
23
+
24
+ it 'allows a chunk to be exactly the configured limit' do
25
+ chunks = described_class.new(6).chunks(%w[123 456])
26
+
27
+ expect(chunks).to eq([%w[123 456]])
28
+ end
29
+
30
+ it 'keeps an oversized event intact and places it in its own chunk' do
31
+ oversized = 'x' * 11
32
+ chunks = described_class.new(10).chunks(['before', oversized, 'after'])
33
+
34
+ expect(chunks).to eq([['before'], [oversized], ['after']])
35
+ end
36
+
37
+ it 'measures bytes instead of characters' do
38
+ multibyte = "\u20ac\u20ac" # six UTF-8 bytes
39
+ chunks = described_class.new(6).chunks([multibyte, 'x'])
40
+
41
+ expect(chunks).to eq([[multibyte], ['x']])
42
+ end
43
+
44
+ it 'preserves event order and encoded separators' do
45
+ encoded = ["{\"id\":1}\n", "{\"id\":2}\n", "{\"id\":3}\n"]
46
+ chunks = described_class.new(18).chunks(encoded)
47
+
48
+ expect(chunks.flatten).to eq(encoded)
49
+ expect(chunks.map(&:join)).to eq(["{\"id\":1}\n{\"id\":2}\n", "{\"id\":3}\n"])
50
+ end
51
+
52
+ it 'rejects a non-positive byte limit' do
53
+ expect { described_class.new(0) }.to raise_error(ArgumentError)
54
+ expect { described_class.new(-1) }.to raise_error(ArgumentError)
55
+ end
56
+ end
57
+ end