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.
- 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
|