streamdal 0.0.1

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.
data/lib/streamdal.rb ADDED
@@ -0,0 +1,852 @@
1
+ # frozen_string_literal: true
2
+ require 'base64'
3
+ require 'httparty'
4
+ require 'logger'
5
+ require 'securerandom'
6
+ require 'wasmtime'
7
+ require 'sp_sdk_pb'
8
+ require 'sp_common_pb'
9
+ require 'sp_info_pb'
10
+ require 'sp_internal_pb'
11
+ require "sp_internal_services_pb"
12
+ require 'sp_pipeline_pb'
13
+ require "sp_wsm_pb"
14
+ require "steps/sp_steps_httprequest_pb"
15
+ require "steps/sp_steps_kv_pb"
16
+ require 'timeout'
17
+ require 'google/protobuf'
18
+ require_relative 'audiences'
19
+ require_relative 'hostfunc'
20
+ require_relative 'kv'
21
+ require_relative 'metrics'
22
+ require_relative 'schema'
23
+ require_relative 'tail'
24
+ require_relative 'validation'
25
+
26
+ DEFAULT_GRPC_RECONNECT_INTERVAL = 5 # 5 seconds
27
+ DEFAULT_PIPELINE_TIMEOUT = 1 / 10 # 100 milliseconds
28
+ DEFAULT_STEP_TIMEOUT = 1 / 100 # 10 milliseconds
29
+ DEFAULT_GRPC_TIMEOUT = 5 # 5 seconds
30
+ DEFAULT_HEARTBEAT_INTERVAL = 1 # 1 second
31
+ MAX_PAYLOAD_SIZE = 1024 * 1024 # 1 megabyte
32
+
33
+ module Streamdal
34
+
35
+ OPERATION_TYPE_PRODUCER = 2
36
+ OPERATION_TYPE_CONSUMER = 1
37
+ CLIENT_TYPE_SDK = 1
38
+ CLIENT_TYPE_SHIM = 2
39
+
40
+ # Data class to hold instantiated wasm functions
41
+ class WasmFunction
42
+
43
+ ##
44
+ # Instance of an initialized wasm module and associated memory store
45
+
46
+ attr_accessor :instance, :store
47
+
48
+ def initialize
49
+ @instance = nil
50
+ @store = nil
51
+ end
52
+
53
+ end
54
+
55
+ ##
56
+ # Data class to store/pass audiences
57
+
58
+ Audience = Struct.new(:operation_type, :operation_name, :component_name) do
59
+ def to_proto(service_name)
60
+ Streamdal::Protos::Audience.new(
61
+ operation_type: Streamdal::Protos::OperationType.lookup(operation_type.to_i),
62
+ operation_name: operation_name,
63
+ component_name: component_name,
64
+ service_name: service_name,
65
+ )
66
+ end
67
+ end
68
+
69
+
70
+ class Client
71
+
72
+ ##
73
+ # Streamdal SDK Client
74
+ #
75
+ # There is only one public method: process(data, audience)
76
+ include Audiences
77
+ include Validation
78
+ include Schemas
79
+
80
+ # Aliases to keep lines short
81
+ CounterEntry = Streamdal::Metrics::CounterEntry
82
+ Metrics = Streamdal::Metrics
83
+
84
+ def initialize(cfg)
85
+ _validate_cfg(cfg)
86
+
87
+ @cfg = cfg
88
+ @log = cfg[:log]
89
+ @functions = {}
90
+ @session_id = SecureRandom.uuid
91
+ @pipelines = {}
92
+ @audiences = {}
93
+ @schemas = {}
94
+ @tails = {}
95
+ @paused_tails = {}
96
+ @metrics = Metrics.new(cfg)
97
+ @workers = []
98
+ @exit = false
99
+ @kv = Streamdal::KeyValue.new
100
+ @hostfunc = Streamdal::HostFunc.new(@kv)
101
+
102
+ # # Connect to Streamdal External gRPC API
103
+ @stub = Streamdal::Protos::Internal::Stub.new(@cfg[:streamdal_url], :this_channel_is_insecure)
104
+
105
+ _pull_initial_pipelines
106
+
107
+ @workers << Thread.new { _heartbeat }
108
+ @workers << Thread.new { _register }
109
+ end
110
+
111
+ def shutdown
112
+ # Set exit flag so workers exit
113
+ @exit = true
114
+
115
+ # Let loops exit
116
+ sleep(1)
117
+
118
+ # Exit any remaining threads
119
+ @workers.each do |w|
120
+ if w.running?
121
+ w.exit
122
+ end
123
+ end
124
+ end
125
+
126
+ def process(data, audience)
127
+ if data.length == 0
128
+ raise "data is required"
129
+ end
130
+
131
+ if audience.nil?
132
+ raise "audience is required"
133
+ end
134
+
135
+ resp = Streamdal::Protos::SDKResponse.new
136
+ resp.status = :EXEC_STATUS_TRUE
137
+ resp.pipeline_status = Google::Protobuf::RepeatedField.new(:message, Streamdal::Protos::PipelineStatus, [])
138
+ resp.data = data
139
+
140
+ aud = audience.to_proto(@cfg[:service_name])
141
+
142
+ labels = {
143
+ "service": @cfg[:service_name],
144
+ "operation_type": aud.operation_type,
145
+ "operation": aud.operation_name,
146
+ "component": aud.component_name,
147
+ "pipeline_name": "",
148
+ "pipeline_id": "",
149
+ }
150
+
151
+ # TODO: metrics
152
+ bytes_processed = Metrics::COUNTER_CONSUME_BYTES
153
+ errors_counter = Metrics::COUNTER_CONSUME_ERRORS
154
+ total_counter = Metrics::COUNTER_CONSUME_PROCESSED
155
+ rate_processed = Metrics::COUNTER_CONSUME_PROCESSED_RATE
156
+
157
+ if aud.operation_type == OPERATION_TYPE_PRODUCER
158
+ bytes_processed = Metrics::COUNTER_PRODUCE_BYTES
159
+ errors_counter = Metrics::COUNTER_PRODUCE_ERRORS
160
+ total_counter = Metrics::COUNTER_PRODUCE_PROCESSED
161
+ rate_processed = Metrics::COUNTER_PRODUCE_PROCESSED_RATE
162
+ end
163
+
164
+ payload_size = data.length
165
+
166
+ if payload_size > MAX_PAYLOAD_SIZE
167
+ # TODO: add metrics
168
+ resp.status = :EXEC_STATUS_ERROR
169
+ resp.error = "payload size exceeds maximum allowed size"
170
+ resp
171
+ end
172
+
173
+ # Needed for send_tail()
174
+ original_data = data
175
+
176
+ pipelines = _get_pipelines(aud)
177
+ if pipelines.length == 0
178
+ _send_tail(aud, "", original_data, original_data)
179
+ return resp
180
+ end
181
+
182
+ @metrics.incr(CounterEntry.new(bytes_processed, aud, labels, data.length))
183
+ @metrics.incr(CounterEntry.new(rate_processed, aud, labels, 1))
184
+
185
+ # Used for passing data between steps
186
+ isr = nil
187
+
188
+ Timeout::timeout(@cfg[:pipeline_timeout]) do
189
+ pipelines.each do |pipeline|
190
+ pipeline_status = Streamdal::Protos::PipelineStatus.new
191
+ pipeline_status.id = pipeline.id
192
+ pipeline_status.name = pipeline.name
193
+ pipeline_status.step_status = Google::Protobuf::RepeatedField.new(:message, Streamdal::Protos::StepStatus, [])
194
+
195
+ @log.debug "Running pipeline: '#{pipeline.name}'"
196
+
197
+ labels[:pipeline_id] = pipeline.id
198
+ labels[:pipeline_name] = pipeline.name
199
+
200
+ @metrics.incr(CounterEntry.new(total_counter, aud, labels, 1))
201
+ @metrics.incr(CounterEntry.new(bytes_processed, aud, labels, data.length))
202
+
203
+ pipeline.steps.each do |step|
204
+ step_status = Streamdal::Protos::StepStatus.new
205
+ step_status.name = step.name
206
+ step_status.status = :EXEC_STATUS_TRUE
207
+
208
+ begin
209
+ wasm_resp = _call_wasm(step, data, isr)
210
+ rescue => e
211
+ @log.error "Error running step '#{step.name}': #{e}"
212
+ step_status.status = :EXEC_STATUS_ERROR
213
+ step_status.error = e.to_s
214
+ pipeline_status.step_status.push(step_status)
215
+ break
216
+ end
217
+
218
+ if @cfg[:dry_run]
219
+ @log.debug "Running step '#{step.name}' in dry-run mode"
220
+ end
221
+
222
+ if wasm_resp.output_payload.length > 0
223
+ resp.data = wasm_resp.output_payload
224
+ end
225
+
226
+ _handle_schema(aud, step, wasm_resp)
227
+
228
+ isr = wasm_resp.inter_step_result
229
+
230
+ case wasm_resp.exit_code
231
+ when :WASM_EXIT_CODE_FALSE
232
+ cond = step.on_false
233
+ cond_type = :CONDITION_TYPE_ON_FALSE
234
+ exec_status = :EXEC_STATUS_FALSE
235
+ when :WASM_EXIT_CODE_ERROR
236
+ cond = step.on_error
237
+ cond_type = :CONDITION_TYPE_ON_ERROR
238
+ exec_status = :EXEC_STATUS_ERROR
239
+ isr = nil
240
+
241
+ # errors_counter, 1, labels, aud
242
+ @metrics.incr(CounterEntry.new(errors_counter, aud, labels, 1))
243
+ else
244
+ cond = step.on_true
245
+ exec_status = :EXEC_STATUS_TRUE
246
+ cond_type = :CONDITION_TYPE_ON_TRUE
247
+ end
248
+
249
+ _notify_condition(pipeline, step, aud, cond, resp.data, cond_type)
250
+
251
+ if @cfg[:dry_run]
252
+ @log.debug("Step '#{step.name}' completed with status: #{exec_status}, continuing to next step")
253
+ next
254
+ end
255
+
256
+ # Whether we are aborting early, aborting current, or continuing, we need to set the step status
257
+ step_status.status = exec_status
258
+ step_status.status_message = "Step returned: #{wasm_resp.exit_msg}"
259
+
260
+ # Pull metadata from step into SDKResponse
261
+ unless cond.nil?
262
+ resp.metadata = cond.metadata
263
+
264
+ case cond.abort
265
+ when :ABORT_CONDITION_ABORT_CURRENT
266
+ step_status.status = exec_status
267
+ step_status.status_message = "Step returned: #{wasm_resp.exit_msg}"
268
+ pipeline_status.step_status.push(step_status)
269
+ resp.pipeline_status.push(pipeline_status)
270
+ # Continue outer pipeline loop, there might be additional pipelines
271
+ break
272
+ when :ABORT_CONDITION_ABORT_ALL
273
+ # Set step status and push to pipeline status
274
+ step_status.status = exec_status
275
+ step_status.status_message = "Step returned: #{wasm_resp.exit_msg}"
276
+ pipeline_status.step_status.push(step_status)
277
+ resp.pipeline_status.push(pipeline_status)
278
+
279
+ # Since we're returning early here, also need to set the response status
280
+ resp.status = exec_status
281
+ resp.status_message = "Step returned: #{wasm_resp.exit_msg}"
282
+
283
+ _send_tail(aud, pipeline.id, original_data, resp.data)
284
+ return resp
285
+ else
286
+ # Do nothing
287
+ end
288
+ end
289
+
290
+ # Append step status to the current pipeline status' array
291
+ pipeline_status.step_status.push(step_status)
292
+ end
293
+
294
+ # Append pipeline status to the response
295
+ resp.pipeline_status.push(pipeline_status)
296
+ end # pipelines.each
297
+ end # timeout
298
+
299
+ _send_tail(aud, "", original_data, resp.data)
300
+
301
+ if @cfg[:dry_run]
302
+ @log.debug "Dry-run, setting response data to original data"
303
+ resp.data = original_data
304
+ end
305
+
306
+ resp
307
+ end
308
+
309
+ private
310
+
311
+ def _validate_cfg(cfg)
312
+ if cfg[:streamdal_url].nil? || cfg[:streamdal_url].empty?
313
+ raise "streamdal_url is required"
314
+ end
315
+
316
+ if cfg[:streamdal_token].nil? || cfg[:streamdal_token].empty?
317
+ raise "streamdal_token is required"
318
+ end
319
+
320
+ if cfg[:service_name].nil? || cfg[:streamdal_token].empty?
321
+ raise "service_name is required"
322
+ end
323
+
324
+ if cfg[:log].nil? || cfg[:streamdal_token].empty?
325
+ logger = Logger.new(STDOUT)
326
+ logger.level = Logger::ERROR
327
+ cfg[:log] = logger
328
+ end
329
+
330
+ if cfg[:pipeline_timeout].nil?
331
+ cfg[:pipeline_timeout] = DEFAULT_PIPELINE_TIMEOUT
332
+ end
333
+
334
+ if cfg[:step_timeout].nil?
335
+ cfg[:step_timeout] = DEFAULT_STEP_TIMEOUT
336
+ end
337
+ end
338
+
339
+ def _handle_command(cmd)
340
+ case cmd.command.to_s
341
+ when "kv"
342
+ _handle_kv(cmd)
343
+ when "tail"
344
+ _handle_tail_request(cmd)
345
+ when "set_pipelines"
346
+ _set_pipelines(cmd)
347
+ when "keep_alive"
348
+ # Do nothing
349
+ else
350
+ @log.error "unknown command type #{cmd.command}"
351
+ end
352
+ end
353
+
354
+ def _handle_kv(cmd)
355
+ begin
356
+ validate_kv_command(cmd)
357
+ rescue => e
358
+ @log.error "KV command validation failed: #{e}"
359
+ return nil
360
+ end
361
+
362
+ cmd.kv.instructions.each do |inst|
363
+ validate_kv_instruction(inst)
364
+
365
+ case inst.action
366
+ when :KV_ACTION_CREATE
367
+ @kv.set(inst.key, inst.value)
368
+ when :KV_ACTION_UPDATE
369
+ @kv.set(inst.key, inst.value)
370
+ when :KV_ACTION_DELETE
371
+ @kv.delete(inst.key)
372
+ when :KV_ACTION_DELETE_ALL
373
+ @kv.purge
374
+ else
375
+ @log.error "Unknown KV action: '#{inst.action}'"
376
+ end
377
+ end
378
+ end
379
+
380
+ def _set_pipelines(cmd)
381
+ if cmd.nil?
382
+ raise "cmd is required"
383
+ end
384
+
385
+ cmd.set_pipelines.pipelines.each_with_index { |p, pIdx|
386
+ p.steps.each_with_index { |step, idx|
387
+ if step._wasm_bytes == ""
388
+ if cmd.set_pipelines.wasm_modules.has_key?(step._wasm_id)
389
+ step._wasm_bytes = cmd.set_pipelines.wasm_modules[step._wasm_id].bytes
390
+ cmd.set_pipelines.pipelines[pIdx].steps[idx] = step
391
+ else
392
+ @log.error "WASM module not found for step: #{step._wasm_id}"
393
+ end
394
+ end
395
+ }
396
+
397
+ aud_str = aud_to_str(cmd.audience)
398
+ @pipelines.key?(aud_str) ? @pipelines[aud_str].push(p) : @pipelines[aud_str] = [p]
399
+ }
400
+ end
401
+
402
+ def _pull_initial_pipelines
403
+ req = Streamdal::Protos::GetSetPipelinesCommandsByServiceRequest.new
404
+ req.service_name = @cfg[:service_name]
405
+
406
+ resp = @stub.get_set_pipelines_commands_by_service(req, metadata: _metadata)
407
+
408
+ @log.debug "Received '#{resp.set_pipeline_commands.length}' initial pipelines"
409
+
410
+ resp.set_pipeline_commands.each do |cmd|
411
+ cmd.set_pipelines.wasm_modules = resp.wasm_modules
412
+ _set_pipelines(cmd)
413
+ end
414
+ end
415
+
416
+ def _get_function(step)
417
+ # We cache functions so we can eliminate the wasm bytes from steps to save on memory
418
+ # And also to avoid re-initializing the same function multiple times
419
+ if @functions.key?(step._wasm_id)
420
+ return @functions[step._wasm_id]
421
+ end
422
+
423
+ engine = Wasmtime::Engine.new
424
+ mod = Wasmtime::Module.new(engine, step._wasm_bytes)
425
+ linker = Wasmtime::Linker.new(engine, wasi: true)
426
+
427
+ linker.func_new("env", "httpRequest", [:i32, :i32], [:i64]) do |caller, ptr, len|
428
+ @hostfunc.http_request(caller, ptr, len)
429
+ end
430
+
431
+ linker.func_new("env", "kvExists", [:i32, :i32], [:i64]) do |caller, ptr, len|
432
+ @hostfunc.kv_exists(caller, ptr, len)
433
+ end
434
+
435
+ wasi_ctx = Wasmtime::WasiCtxBuilder.new
436
+ .inherit_stdout
437
+ .inherit_stderr
438
+ .set_argv(ARGV)
439
+ .set_env(ENV)
440
+ .build
441
+ store = Wasmtime::Store.new(engine, wasi_ctx: wasi_ctx)
442
+
443
+ instance = linker.instantiate(store, mod)
444
+
445
+ # TODO: host funcs
446
+
447
+ # Store in cache
448
+ func = WasmFunction.new
449
+ func.instance = instance
450
+ func.store = store
451
+ @functions[step._wasm_id] = func
452
+
453
+ func
454
+ end
455
+
456
+ def _call_wasm(step, data, isr)
457
+ if step.nil?
458
+ raise "step is required"
459
+ end
460
+
461
+ if data.nil?
462
+ raise "data is required"
463
+ end
464
+
465
+ if isr.nil?
466
+ isr = Streamdal::Protos::InterStepResult.new
467
+ end
468
+
469
+ req = Streamdal::Protos::WASMRequest.new
470
+ req.step = step.clone
471
+ req.input_payload = data
472
+ req.inter_step_result = isr
473
+
474
+ begin
475
+ Timeout::timeout(@cfg[:step_timeout]) do
476
+ wasm_resp = _exec_wasm(req)
477
+ return Streamdal::Protos::WASMResponse.decode(wasm_resp)
478
+ end
479
+ rescue => e
480
+ resp = Streamdal::Protos::WASMResponse.new
481
+ resp.exit_code = :WASM_EXIT_CODE_ERROR
482
+ resp.exit_msg = "Failed to execute WASM: #{e}"
483
+ resp.output_payload = ""
484
+ return resp
485
+ end
486
+ end
487
+
488
+ def _gen_register_request
489
+ req = Streamdal::Protos::RegisterRequest.new
490
+ req.service_name = @cfg[:service_name]
491
+ req.session_id = @session_id
492
+ req.dry_run = @cfg[:dry_run] || false
493
+ req.client_info = _gen_client_info
494
+
495
+ req
496
+ end
497
+
498
+ def _gen_client_info
499
+ arch, os = RUBY_PLATFORM.split(/-/)
500
+
501
+ ci = Streamdal::Protos::ClientInfo.new
502
+ ci.client_type = :CLIENT_TYPE_SDK
503
+ ci.library_name = "ruby-sdk"
504
+ ci.library_version = "0.0.1"
505
+ ci.language = "ruby"
506
+ ci.arch = arch
507
+ ci.os = os
508
+
509
+ ci
510
+ end
511
+
512
+ # Returns metadata for gRPC requests to the internal gRPC API
513
+ def _metadata
514
+ { "auth-token" => @cfg[:streamdal_token].to_s }
515
+ end
516
+
517
+ def _register
518
+ @log.info("register started")
519
+
520
+ # Register with Streamdal External gRPC API
521
+ resps = @stub.register(_gen_register_request, metadata: _metadata)
522
+ resps.each do |r|
523
+ if @exit
524
+ break
525
+ end
526
+
527
+ _handle_command(r)
528
+ end
529
+
530
+ @log.info("register exited")
531
+ end
532
+
533
+ def _exec_wasm(req)
534
+ wasm_func = _get_function(req.step)
535
+
536
+ # Empty out _wasm_bytes, we don't need it anymore
537
+ # TODO: does this actually update the original object?
538
+ req.step._wasm_bytes = ""
539
+
540
+ data = req.to_proto
541
+
542
+ memory = wasm_func.instance.export("memory").to_memory
543
+ alloc = wasm_func.instance.export("alloc").to_func
544
+ dealloc = wasm_func.instance.export("dealloc").to_func
545
+ f = wasm_func.instance.export("f").to_func
546
+
547
+ start_ptr = alloc.call(data.length)
548
+
549
+ memory.write(start_ptr, data)
550
+
551
+ # Result is a 64bit int where the first 32 bits are the pointer to the result
552
+ # and the last 32 bits are the length of the result. This is due to the fact
553
+ # that we can only return an integer from a wasm function.
554
+ result_ptr = f.call(start_ptr, data.length)
555
+ ptr_true = result_ptr >> 32
556
+ len_true = result_ptr & 0xFFFFFFFF
557
+
558
+ res = memory.read(ptr_true, len_true)
559
+
560
+ # Dealloc result memory since we already read it
561
+ dealloc.call(ptr_true, res.length)
562
+
563
+ res
564
+ end
565
+
566
+ def _get_pipelines(aud)
567
+ aud_str = aud_to_str(aud)
568
+
569
+ _add_audience(aud)
570
+
571
+ if @pipelines.key?(aud_str)
572
+ return @pipelines[aud_str]
573
+ end
574
+
575
+ []
576
+ end
577
+
578
+ def _heartbeat
579
+ until @exit
580
+ req = Streamdal::Protos::HeartbeatRequest.new
581
+ req.session_id = @session_id
582
+ req.audiences = Google::Protobuf::RepeatedField.new(:message, Streamdal::Protos::Audience, [])
583
+
584
+ @audiences.each do |_, aud|
585
+ req.audiences.push(aud)
586
+ end
587
+
588
+ req.client_info = _gen_client_info
589
+ req.service_name = @cfg[:service_name]
590
+
591
+ @stub.heartbeat(req, metadata: _metadata)
592
+ sleep(DEFAULT_HEARTBEAT_INTERVAL)
593
+ end
594
+ end
595
+
596
+ ######################################################################################
597
+ # Tail methods
598
+ ######################################################################################
599
+
600
+ def _handle_tail_request(cmd)
601
+ validate_tail_request(cmd)
602
+
603
+ case cmd.tail.request.type
604
+ when :TAIL_REQUEST_TYPE_START
605
+ _start_tail(cmd)
606
+ when :TAIL_REQUEST_TYPE_STOP
607
+ _stop_tail(cmd)
608
+ when :TAIL_REQUEST_TYPE_PAUSE
609
+ _pause_tail(cmd)
610
+ when :TAIL_REQUEST_TYPE_RESUME
611
+ _resume_tail(cmd)
612
+ else
613
+ raise "unknown tail request type: '#{cmd.tail.request.type.inspect}'"
614
+ end
615
+ end
616
+
617
+ def _get_active_tails_for_audience(aud)
618
+ aud_str = aud_to_str(aud)
619
+ if @tails.key?(aud_str)
620
+ return @tails[aud_str].values
621
+ end
622
+
623
+ []
624
+ end
625
+
626
+ def _send_tail(aud, pipeline_id, original_data, new_data)
627
+ tails = _get_active_tails_for_audience(aud)
628
+ if tails.length == 0
629
+ return nil
630
+ end
631
+
632
+ tails.each do |tail|
633
+ req = Streamdal::Protos::TailResponse.new
634
+ req.type = :TAIL_RESPONSE_TYPE_PAYLOAD
635
+ req.audience = aud
636
+ req.pipeline_id = pipeline_id
637
+ req.session_id = @session_id
638
+ req.timestamp_ns = Time.now.to_i
639
+ req.original_data = original_data
640
+ req.new_data = new_data
641
+ req.tail_request_id = tail.request.id
642
+ tail.queue.push(req)
643
+ end
644
+ end
645
+
646
+ def _notify_condition(pipeline, step, aud, cond, data, cond_type)
647
+ if cond.nil?
648
+ return nil
649
+ end
650
+
651
+ if cond.notification.nil?
652
+ return nil
653
+ end
654
+
655
+ @log.debug "Notifying"
656
+
657
+ if @cfg[:dry_run]
658
+ return nil
659
+ end
660
+
661
+ @metrics.incr(CounterEntry.new(Metrics::COUNTER_NOTIFY, aud, {
662
+ "service": @cfg[:service_name],
663
+ "component_name": aud.component_name,
664
+ "pipeline_name": pipeline.name,
665
+ "pipeline_id": pipeline.id,
666
+ "operation_name": aud.operation_name,
667
+ }, 1))
668
+
669
+ req = Streamdal::Protos::NotifyRequest.new
670
+ req.audience = aud
671
+ req.pipeline_id = pipeline.id
672
+ req.step = step
673
+ req.payload = data
674
+ req.condition_type = cond_type
675
+ req.occurred_at_unix_ts_utc = Time.now.to_i
676
+
677
+ Thread.new do
678
+ @stub.notify(req, metadata: _metadata)
679
+ end
680
+ end
681
+
682
+ def _start_tail(cmd)
683
+ validate_tail_request(cmd)
684
+
685
+ req = cmd.tail.request
686
+ @log.debug "Starting tail '#{req.id}'"
687
+
688
+ aud_str = aud_to_str(cmd.tail.request.audience)
689
+
690
+ # Do we already have a tail for this audience
691
+ if @tails.key?(aud_str) && @tails[aud_str].key?(req.id)
692
+ @log.error "Tail '#{req.id}' already exists, skipping TailCommand"
693
+ return
694
+ end
695
+
696
+ @log.debug "Tailing audience: #{aud_str}"
697
+
698
+ t = Streamdal::Tail.new(
699
+ req,
700
+ @cfg[:streamdal_url],
701
+ @cfg[:streamdal_token],
702
+ @cfg[:log],
703
+ @metrics
704
+ )
705
+
706
+ t.start_tail_workers
707
+
708
+ _set_active_tail(t)
709
+
710
+ end
711
+
712
+ def _set_active_tail(tail)
713
+ key = aud_to_str(tail.request.audience)
714
+
715
+ unless @tails.key?(key)
716
+ @tails[key] = {}
717
+ end
718
+
719
+ @tails[key][tail.request.id] = tail
720
+ end
721
+
722
+ def _set_paused_tail(tail)
723
+ key = aud_to_str(tail.request.aud)
724
+
725
+ unless @paused_tails.key?(key)
726
+ @paused_tails[key] = {}
727
+ end
728
+
729
+ @paused_tails[key][tail.request.id] = tail
730
+ end
731
+
732
+ def _stop_tail(cmd)
733
+ @log.debug "Stopping tail '#{cmd.tail.request.id}'"
734
+ key = aud_to_str(cmd.tail.request.audience)
735
+
736
+ if @tails.key?(key) && @tails[key].key?(cmd.tail.request.id)
737
+ @tails[key][cmd.tail.request.id].stop_tail
738
+
739
+ # Remove from active tails
740
+ @tails[key].delete(cmd.tail.request.id)
741
+
742
+ if @tails[key].length == 0
743
+ @tails.delete(key)
744
+ end
745
+ end
746
+
747
+ if @paused_tails.key?(key) && @paused_tails[key].key?(cmd.tail.request.id)
748
+ @paused_tails[key].delete(cmd.tail.request.id)
749
+
750
+ if @paused_tails[key].length == 0
751
+ @paused_tails.delete(key)
752
+ end
753
+ end
754
+ end
755
+
756
+ def _stop_all_tails
757
+ # TODO: does this modify the instances variables or copy them?
758
+ _stop_tails(@tails)
759
+ _stop_tails(@paused_tails)
760
+ end
761
+
762
+ def _stop_tails(tails = {})
763
+ # Helper method for _stop_all_tails
764
+ tails.each do |aud, aud_tails|
765
+ aud_tails.each do |t|
766
+ t.stop_tail
767
+ tails[aud].delete(tail.request.id)
768
+
769
+ if tails[aud].length == 0
770
+ tails.delete(aud)
771
+ end
772
+ end
773
+ end
774
+ end
775
+
776
+ def _pause_tail(cmd)
777
+ t = _remove_active_tail(cmd.tail.request.audience, cmd.tail.request.tail.id)
778
+ t.stop_tail
779
+
780
+ _set_paused_tail(t)
781
+
782
+ @log.debug "Paused tail '#{cmd.tail.request.tail.id}'"
783
+ end
784
+
785
+ def _resume_tail(cmd)
786
+ t = _remove_paused_tail(cmd.tail.request.audience, cmd.tail.request.tail.id)
787
+ if t.nil?
788
+ @log.error "Tail '#{cmd.tail.request.tail.id}' not found in paused tails"
789
+ return nil
790
+ end
791
+
792
+ t.start_tail_workers
793
+
794
+ _set_active_tail(t)
795
+
796
+ @log.debug "Resumed tail '#{cmd.tail.request.tail.id}'"
797
+ end
798
+
799
+ def _remove_active_tail(aud, tail_id)
800
+ key = aud_to_str(aud)
801
+
802
+ if @tails.key?(key) && @tails[key].key?(tail_id)
803
+ t = @tails[key][tail_id]
804
+ t.stop_tail
805
+
806
+ @tails[key].delete(tail_id)
807
+
808
+ if @tails[key].length == 0
809
+ @tails.delete(key)
810
+ end
811
+
812
+ t
813
+ end
814
+ end
815
+
816
+ def _remove_paused_tail(aud, tail_id)
817
+ key = aud_to_str(aud)
818
+
819
+ if @paused_tails.key?(key) && @paused_tails[key].key?(tail_id)
820
+ t = @paused_tails[key][tail_id]
821
+
822
+ @paused_tails[key].delete(tail_id)
823
+
824
+ if @paused_tails[key].length == 0
825
+ @paused_tails.delete(key)
826
+ end
827
+
828
+ t
829
+ end
830
+ end
831
+
832
+ # Called by host functions to write memory to wasm instance so that
833
+ # the wasm module can read the result of a host function call
834
+ def write_to_memory(caller, res)
835
+ alloc = caller.export("alloc").to_func
836
+ memory = caller.export("memory").to_memory
837
+
838
+ # Serialize protobuf message
839
+ resp = res.to_proto
840
+
841
+ # Allocate memory for response
842
+ resp_ptr = alloc.call(resp.length)
843
+
844
+ # Write response to memory
845
+ memory.write(resp_ptr, resp)
846
+
847
+ # return 64bit integer where first 32 bits is the pointer, and the last 32 is the length
848
+ resp_ptr << 32 | resp.length
849
+ end
850
+
851
+ end
852
+ end