streamdal 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/audiences.rb +45 -0
- data/lib/audiences_spec.rb +5 -0
- data/lib/hostfunc.rb +109 -0
- data/lib/hostfunc_spec.rb +5 -0
- data/lib/kv.rb +52 -0
- data/lib/kv_spec.rb +54 -0
- data/lib/metrics.rb +265 -0
- data/lib/metrics_spec.rb +5 -0
- data/lib/schema.rb +47 -0
- data/lib/schema_spec.rb +59 -0
- data/lib/spec_helper.rb +2 -0
- data/lib/streamdal.rb +852 -0
- data/lib/streamdal_spec.rb +5 -0
- data/lib/tail.rb +97 -0
- data/lib/tail_spec.rb +5 -0
- data/lib/validation.rb +88 -0
- data/lib/validation_spec.rb +77 -0
- metadata +59 -0
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
|