streamdal 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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