hatchet-sdk 0.11.2 → 0.12.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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +1 -0
- data/lib/hatchet/clients/rest/.openapi-generator/FILES +13 -0
- data/lib/hatchet/clients/rest/README.md +15 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/api/concurrency_api.rb +188 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/api/serverless_api.rb +77 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/api_token.rb +28 -1
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/create_api_token_request.rb +11 -1
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/feature_flag_id.rb +2 -1
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/tenant_entitlements.rb +30 -4
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_key.rb +326 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_key_list.rb +275 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_limit_strategy.rb +44 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_scope.rb +41 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_strategy.rb +676 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_strategy_list.rb +239 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_strategy_usage.rb +307 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_create_serverless_endpoint_request.rb +1 -1
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_check_stage.rb +42 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event.rb +363 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event_details.rb +358 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event_kind.rb +44 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event_list.rb +239 -0
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_kind.rb +2 -1
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_stream_topic.rb +31 -4
- data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest.rb +13 -0
- data/lib/hatchet/contracts/v1/streams_pb.rb +28 -0
- data/lib/hatchet/contracts/v1/streams_services_pb.rb +26 -0
- data/lib/hatchet/features/streams.rb +498 -0
- data/lib/hatchet/version.rb +1 -1
- data/lib/hatchet-sdk.rb +11 -0
- data/sig/hatchet/features/streams.rbs +39 -0
- data/sig/hatchet-sdk.rbs +1 -0
- metadata +19 -2
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "grpc"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Hatchet
|
|
8
|
+
module Features
|
|
9
|
+
# A message read from a durable stream topic.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute payload
|
|
12
|
+
# @return [String] The message payload, as binary
|
|
13
|
+
# @!attribute cursor
|
|
14
|
+
# @return [String] Resumes {Streams#subscribe} right after this event
|
|
15
|
+
# @!attribute created_at
|
|
16
|
+
# @return [Time, nil] When the message was stored
|
|
17
|
+
StreamEvent = Struct.new(:payload, :cursor, :created_at, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
# What a durable stream topic retains; counts and the latest message cover the tenant's retention.
|
|
20
|
+
#
|
|
21
|
+
# @!attribute latest_cursor
|
|
22
|
+
# @return [String, nil] Resumes {Streams#subscribe} after the newest retained message; nil when none is retained
|
|
23
|
+
# @!attribute last_published_at
|
|
24
|
+
# @return [Time, nil] When the newest retained message was stored; nil when none is retained
|
|
25
|
+
StreamTopicMetadata = Struct.new(
|
|
26
|
+
:namespace, :topic, :tenant_id, :message_count, :latest_cursor, :last_published_at,
|
|
27
|
+
keyword_init: true,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Streams client for publishing to and reading from durable topics. Unlike
|
|
31
|
+
# +runs.subscribe_to_stream+, topics are stored, independent of any run, and
|
|
32
|
+
# readers can resume from a cursor.
|
|
33
|
+
#
|
|
34
|
+
# @example Publishing and reading
|
|
35
|
+
# hatchet.streams.publish("orders", '{"status":"shipped"}')
|
|
36
|
+
# hatchet.streams.subscribe("orders") { |event| puts event.payload }
|
|
37
|
+
#
|
|
38
|
+
# @since 0.12.0
|
|
39
|
+
class Streams
|
|
40
|
+
# the server's limit for a payload sent over gRPC (MaxStreamMessagePayloadBytes)
|
|
41
|
+
MAX_INLINE_PAYLOAD_BYTES = (4 * 1024 * 1024) - (5 * 1024)
|
|
42
|
+
|
|
43
|
+
# the server's limit for an uploaded payload (MaxStreamUploadedPayloadBytes)
|
|
44
|
+
MAX_UPLOADED_PAYLOAD_BYTES = 64 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
# the most publishes one PublishBatch request carries; the engine sets no limit of its own
|
|
47
|
+
MAX_PUBLISH_BATCH_ENTRIES = 500
|
|
48
|
+
|
|
49
|
+
# keeps a PublishBatch request under gRPC's 4 MiB limit with the headroom of a single max-size publish
|
|
50
|
+
MAX_PUBLISH_BATCH_BYTES = (4 * 1024 * 1024) - (5 * 1024)
|
|
51
|
+
|
|
52
|
+
# an entry's tag and length prefixes, rounded up
|
|
53
|
+
PUBLISH_ENTRY_OVERHEAD_BYTES = 16
|
|
54
|
+
|
|
55
|
+
# an upload's ref, which a batch carries in place of its payload, rounded up
|
|
56
|
+
PAYLOAD_REF_BYTES = 256
|
|
57
|
+
|
|
58
|
+
Config = Struct.new(:payload_request_attempts, :resubscribe_min_delay_seconds, :resubscribe_max_delay_seconds) do
|
|
59
|
+
def initialize(payload_request_attempts: 3, resubscribe_min_delay_seconds: 0.25, resubscribe_max_delay_seconds: 10.0)
|
|
60
|
+
super
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# the server stored nothing, so the seq is unused
|
|
65
|
+
REJECTED_BEFORE_STORING = [
|
|
66
|
+
GRPC::Core::StatusCodes::INVALID_ARGUMENT,
|
|
67
|
+
GRPC::Core::StatusCodes::RESOURCE_EXHAUSTED,
|
|
68
|
+
GRPC::Core::StatusCodes::UNAUTHENTICATED,
|
|
69
|
+
GRPC::Core::StatusCodes::PERMISSION_DENIED,
|
|
70
|
+
# an engine without the RPC
|
|
71
|
+
GRPC::Core::StatusCodes::UNIMPLEMENTED,
|
|
72
|
+
].freeze
|
|
73
|
+
|
|
74
|
+
# retrying a subscribe that failed with these can't succeed
|
|
75
|
+
PERMANENT_SUBSCRIBE_ERRORS = [
|
|
76
|
+
GRPC::Core::StatusCodes::INVALID_ARGUMENT,
|
|
77
|
+
GRPC::Core::StatusCodes::PERMISSION_DENIED,
|
|
78
|
+
GRPC::Core::StatusCodes::UNAUTHENTICATED,
|
|
79
|
+
GRPC::Core::StatusCodes::OUT_OF_RANGE,
|
|
80
|
+
GRPC::Core::StatusCodes::FAILED_PRECONDITION,
|
|
81
|
+
GRPC::Core::StatusCodes::NOT_FOUND,
|
|
82
|
+
].freeze
|
|
83
|
+
|
|
84
|
+
Producer = Struct.new(:producer_id, :seq)
|
|
85
|
+
|
|
86
|
+
# a publish waiting to be sent; +uploaded+ is set instead of an inline payload for one too large for a gRPC message
|
|
87
|
+
QueuedPublish = Struct.new(:payload, :uploaded, :send_by, :result) do
|
|
88
|
+
def request_bytes
|
|
89
|
+
uploaded ? PAYLOAD_REF_BYTES : payload.bytesize
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
PublishQueue = Struct.new(:items, :wake)
|
|
94
|
+
|
|
95
|
+
# @param rest_client [Object] The configured REST client
|
|
96
|
+
# @param channel [GRPC::Core::Channel] Shared gRPC channel
|
|
97
|
+
# @param config [Hatchet::Config] The Hatchet configuration
|
|
98
|
+
# @param streams_config [Config] Retry and resubscribe settings
|
|
99
|
+
# @return [void]
|
|
100
|
+
def initialize(rest_client, channel, config, streams_config: Config.new)
|
|
101
|
+
@config = config
|
|
102
|
+
@streams_config = streams_config
|
|
103
|
+
@streams_api = HatchetSdkRest::StreamsApi.new(rest_client)
|
|
104
|
+
@stub = ::V1::V1Streams::Stub.new(config.host_port, nil, channel_override: channel)
|
|
105
|
+
|
|
106
|
+
@mutex = Mutex.new
|
|
107
|
+
# per [namespace, topic]; seq only advances after a publish succeeds
|
|
108
|
+
@producers = {}
|
|
109
|
+
# per [namespace, topic]; one request per topic is in flight at a time so two never share a seq
|
|
110
|
+
@queues = {}
|
|
111
|
+
# set once the engine answers PublishBatch as unimplemented: it predates batching
|
|
112
|
+
@publish_batch_unsupported = false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Publish a message to a topic, returning once it is stored. A client's
|
|
116
|
+
# messages to a topic are delivered in the order +publish+ was called.
|
|
117
|
+
# Publishes to a topic share requests: everything queued while one is in
|
|
118
|
+
# flight goes out in the next, and +batch_timeout+ holds a message up to
|
|
119
|
+
# that long for more to join it.
|
|
120
|
+
#
|
|
121
|
+
# @param topic [String] The topic to publish to
|
|
122
|
+
# @param message [String] The message payload
|
|
123
|
+
# @param namespace [String] The topic's namespace; the empty namespace by default
|
|
124
|
+
# @param batch_timeout [Numeric, nil] Seconds the message may wait for later publishes to the same
|
|
125
|
+
# topic to share its request, bounding the latency batching adds. By default, it is sent as soon
|
|
126
|
+
# as no other request to the topic is in flight.
|
|
127
|
+
# @return [void]
|
|
128
|
+
# @raise [ArgumentError] If the payload is empty or too large, or the batch timeout is negative
|
|
129
|
+
# @raise [GRPC::BadStatus] If the engine rejects the publish
|
|
130
|
+
# @example
|
|
131
|
+
# hatchet.streams.publish("orders", '{"order_id":"ord_123"}', namespace: "customer-a")
|
|
132
|
+
def publish(topic, message, namespace: "", batch_timeout: nil)
|
|
133
|
+
payload = message.to_s.b
|
|
134
|
+
|
|
135
|
+
# the engine would reject the whole request it shares, failing other publishes too
|
|
136
|
+
raise ArgumentError, "payload is empty; a message must carry at least one byte" if payload.empty?
|
|
137
|
+
|
|
138
|
+
if payload.bytesize > MAX_UPLOADED_PAYLOAD_BYTES
|
|
139
|
+
raise ArgumentError, "payload exceeds maximum size of #{MAX_UPLOADED_PAYLOAD_BYTES} bytes"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
timeout = batch_timeout || 0
|
|
143
|
+
raise ArgumentError, "batch_timeout must be 0 or more; got #{batch_timeout}" if timeout.negative?
|
|
144
|
+
|
|
145
|
+
# uploaded before taking its place in the topic's order, so uploads overlap
|
|
146
|
+
uploaded = payload.bytesize > MAX_INLINE_PAYLOAD_BYTES ? start_upload(payload) : nil
|
|
147
|
+
|
|
148
|
+
publish = QueuedPublish.new(payload, uploaded, monotonic_now + timeout, Thread::Queue.new)
|
|
149
|
+
key = [namespace, topic]
|
|
150
|
+
start_drain = false
|
|
151
|
+
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
queue = @queues[key]
|
|
154
|
+
if queue.nil?
|
|
155
|
+
queue = PublishQueue.new([], ConditionVariable.new)
|
|
156
|
+
@queues[key] = queue
|
|
157
|
+
start_drain = true
|
|
158
|
+
end
|
|
159
|
+
queue.items << publish
|
|
160
|
+
# a new publish may fill the request or have a shorter timeout
|
|
161
|
+
queue.wake.signal
|
|
162
|
+
Thread.new { drain_publishes(key, queue) } if start_drain
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
status, err = publish.result.pop
|
|
166
|
+
raise err unless status == :ok
|
|
167
|
+
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Describe a topic's retained messages.
|
|
172
|
+
#
|
|
173
|
+
# @param topic [String] The topic to describe
|
|
174
|
+
# @param namespace [String] The topic's namespace; the empty namespace by default
|
|
175
|
+
# @return [StreamTopicMetadata] The topic's metadata
|
|
176
|
+
# @raise [GRPC::NotFound] For a topic nothing was published to
|
|
177
|
+
def topic_metadata(topic, namespace: "")
|
|
178
|
+
res = @stub.get_topic_metadata(
|
|
179
|
+
::V1::GetStreamTopicMetadataRequest.new(namespace: namespace, topic: topic),
|
|
180
|
+
metadata: @config.auth_metadata,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
StreamTopicMetadata.new(
|
|
184
|
+
namespace: res.namespace,
|
|
185
|
+
topic: res.topic,
|
|
186
|
+
tenant_id: res.tenant_id,
|
|
187
|
+
message_count: res.message_count,
|
|
188
|
+
latest_cursor: res.has_latest_cursor? ? res.latest_cursor : nil,
|
|
189
|
+
last_published_at: to_time(res.last_published_at),
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Make {#subscribe} calls without a cursor start after +cursor+, or the
|
|
194
|
+
# topic's latest message. Readers already iterating, and any call with a
|
|
195
|
+
# cursor, are unaffected.
|
|
196
|
+
#
|
|
197
|
+
# @param topic [String] The topic to reset
|
|
198
|
+
# @param namespace [String] The topic's namespace; the empty namespace by default
|
|
199
|
+
# @param cursor [String, nil] A cursor read off one of the topic's events to reset to, instead of its latest message
|
|
200
|
+
# @return [void]
|
|
201
|
+
# @raise [GRPC::BadStatus] If the engine rejects the reset
|
|
202
|
+
def reset(topic, namespace: "", cursor: nil)
|
|
203
|
+
request = { namespace: namespace, topic: topic }
|
|
204
|
+
request[:cursor] = cursor unless cursor.nil?
|
|
205
|
+
|
|
206
|
+
@stub.reset(::V1::ResetStreamTopicRequest.new(**request), metadata: @config.auth_metadata)
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Read a topic's messages after +cursor+, or from the oldest retained (or,
|
|
211
|
+
# once the topic is reset, after the reset). If the connection drops, it
|
|
212
|
+
# resubscribes from the last message yielded, so nothing is skipped or
|
|
213
|
+
# repeated. Ends when the server hangs up an idle subscription or the
|
|
214
|
+
# block breaks; raises on errors a retry can't fix, such as an expired cursor.
|
|
215
|
+
#
|
|
216
|
+
# @param topic [String] The topic to read from
|
|
217
|
+
# @param namespace [String] The topic's namespace; the empty namespace by default
|
|
218
|
+
# @param cursor [String, nil] A cursor read off a previous event, to resume right after it
|
|
219
|
+
# @yieldparam event [StreamEvent] Each of the topic's events
|
|
220
|
+
# @return [Enumerator<StreamEvent>, nil] An enumerator of the topic's events when no block is given
|
|
221
|
+
# @raise [GRPC::BadStatus] On an error a resubscribe can't fix
|
|
222
|
+
# @example
|
|
223
|
+
# hatchet.streams.subscribe("orders") do |event|
|
|
224
|
+
# puts event.payload
|
|
225
|
+
# break if event.payload == "done"
|
|
226
|
+
# end
|
|
227
|
+
def subscribe(topic, namespace: "", cursor: nil, &block)
|
|
228
|
+
return enum_for(:subscribe, topic, namespace: namespace, cursor: cursor) unless block
|
|
229
|
+
|
|
230
|
+
failures = 0
|
|
231
|
+
attempt = 0
|
|
232
|
+
|
|
233
|
+
loop do
|
|
234
|
+
request = { namespace: namespace, topic: topic }
|
|
235
|
+
request[:cursor] = cursor unless cursor.nil?
|
|
236
|
+
|
|
237
|
+
op = @stub.subscribe(
|
|
238
|
+
::V1::SubscribeStreamRequest.new(**request),
|
|
239
|
+
metadata: @config.auth_metadata,
|
|
240
|
+
return_op: true,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# an error from the caller's block is theirs, not a dropped connection
|
|
244
|
+
delivering = false
|
|
245
|
+
|
|
246
|
+
begin
|
|
247
|
+
op.execute.each do |msg|
|
|
248
|
+
return nil if msg.hangup
|
|
249
|
+
|
|
250
|
+
msg.entries.each do |entry|
|
|
251
|
+
payload = entry.payload_ref.empty? ? entry.payload : fetch_payload(entry.payload_ref)
|
|
252
|
+
cursor = entry.cursor
|
|
253
|
+
# reset only on delivery, so a payload fetch failing on every reconnect still backs off
|
|
254
|
+
failures = 0
|
|
255
|
+
delivering = true
|
|
256
|
+
block.call(StreamEvent.new(payload: payload, cursor: entry.cursor, created_at: to_time(entry.created_at)))
|
|
257
|
+
delivering = false
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
# ending without a hangup means the engine went away, e.g. it is shutting down
|
|
261
|
+
rescue StandardError => e
|
|
262
|
+
raise if delivering || !should_resubscribe?(e, attempt)
|
|
263
|
+
ensure
|
|
264
|
+
op.cancel
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
sleep(resubscribe_delay(failures))
|
|
268
|
+
failures += 1
|
|
269
|
+
attempt += 1
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
def to_time(timestamp)
|
|
276
|
+
timestamp && Time.at(timestamp.seconds, timestamp.nanos, :nsec).utc
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def monotonic_now
|
|
280
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def grpc_code(err)
|
|
284
|
+
err.is_a?(GRPC::BadStatus) ? err.code : nil
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# a payload request that got no response, a 5xx or a 429 may succeed later
|
|
288
|
+
def temporary_http_error?(err)
|
|
289
|
+
code = err.code.to_i
|
|
290
|
+
code.zero? || code >= 500 || code == 429
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def should_resubscribe?(err, attempt)
|
|
294
|
+
return temporary_http_error?(err) if err.is_a?(HatchetSdkRest::ApiError)
|
|
295
|
+
|
|
296
|
+
code = grpc_code(err)
|
|
297
|
+
return false if code.nil? || PERMANENT_SUBSCRIBE_ERRORS.include?(code)
|
|
298
|
+
|
|
299
|
+
# proxies answer 404 while an engine restarts, but on a first attempt it means no streams support
|
|
300
|
+
return attempt.positive? if code == GRPC::Core::StatusCodes::UNIMPLEMENTED
|
|
301
|
+
|
|
302
|
+
true
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def resubscribe_delay(failures)
|
|
306
|
+
delay = [
|
|
307
|
+
@streams_config.resubscribe_min_delay_seconds * (2**[failures, 16].min),
|
|
308
|
+
@streams_config.resubscribe_max_delay_seconds,
|
|
309
|
+
].min
|
|
310
|
+
delay * (0.5 + (rand / 2))
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# retries a 5xx or network failure; a 4xx won't succeed on retry
|
|
314
|
+
def with_retries
|
|
315
|
+
attempt = 1
|
|
316
|
+
begin
|
|
317
|
+
yield
|
|
318
|
+
rescue HatchetSdkRest::ApiError => e
|
|
319
|
+
code = e.code.to_i
|
|
320
|
+
raise if (code.positive? && code < 500) || attempt >= @streams_config.payload_request_attempts
|
|
321
|
+
|
|
322
|
+
attempt += 1
|
|
323
|
+
retry
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def start_upload(payload)
|
|
328
|
+
thread = Thread.new { upload_payload(payload) }
|
|
329
|
+
# its error fails only its own publish, raised when the drain joins it
|
|
330
|
+
thread.report_on_exception = false
|
|
331
|
+
thread
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def upload_payload(payload)
|
|
335
|
+
file = Tempfile.new("hatchet-stream-payload")
|
|
336
|
+
begin
|
|
337
|
+
file.binmode
|
|
338
|
+
file.write(payload)
|
|
339
|
+
file.flush
|
|
340
|
+
with_retries do
|
|
341
|
+
file.rewind
|
|
342
|
+
@streams_api.v1_stream_payload_upload(@config.tenant_id, file).ref
|
|
343
|
+
end
|
|
344
|
+
ensure
|
|
345
|
+
file.close!
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def fetch_payload(payload_ref)
|
|
350
|
+
file = with_retries { @streams_api.v1_stream_payload_get(@config.tenant_id, payload_ref) }
|
|
351
|
+
return file.b if file.is_a?(String)
|
|
352
|
+
|
|
353
|
+
begin
|
|
354
|
+
File.binread(file.path)
|
|
355
|
+
ensure
|
|
356
|
+
file.close
|
|
357
|
+
file.unlink
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def drain_publishes(key, queue)
|
|
362
|
+
while (batch = next_batch(key, queue))
|
|
363
|
+
sent = []
|
|
364
|
+
entries = []
|
|
365
|
+
|
|
366
|
+
batch.each do |p|
|
|
367
|
+
if p.uploaded.nil?
|
|
368
|
+
entries << ::V1::StreamPublishEntry.new(payload: p.payload)
|
|
369
|
+
sent << p
|
|
370
|
+
next
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# a failed upload fails only its own publish, as the rest never depended on it
|
|
374
|
+
begin
|
|
375
|
+
ref = p.uploaded.value
|
|
376
|
+
rescue StandardError => e
|
|
377
|
+
p.result << [:error, e]
|
|
378
|
+
next
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
entries << ::V1::StreamPublishEntry.new(payload_ref: ref)
|
|
382
|
+
sent << p
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
send_publishes(key, sent, entries) unless entries.empty?
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# the next request's publishes, or nil once the queue is empty and removed
|
|
390
|
+
def next_batch(key, queue)
|
|
391
|
+
@mutex.synchronize do
|
|
392
|
+
loop do
|
|
393
|
+
if queue.items.empty?
|
|
394
|
+
# removed under the lock publish takes, so a later publish starts a new drain
|
|
395
|
+
@queues.delete(key)
|
|
396
|
+
return nil
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
count = fitting_publishes(queue.items)
|
|
400
|
+
full = count < queue.items.length || count == MAX_PUBLISH_BATCH_ENTRIES
|
|
401
|
+
wait = queue.items.map(&:send_by).min - monotonic_now
|
|
402
|
+
|
|
403
|
+
return queue.items.shift(count) if full || wait <= 0
|
|
404
|
+
|
|
405
|
+
queue.wake.wait(@mutex, wait)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# always at least one, so a max-size payload still goes out on its own
|
|
411
|
+
def fitting_publishes(items)
|
|
412
|
+
total = 0
|
|
413
|
+
count = 0
|
|
414
|
+
|
|
415
|
+
while count < items.length && count < MAX_PUBLISH_BATCH_ENTRIES
|
|
416
|
+
nxt = items[count].request_bytes + PUBLISH_ENTRY_OVERHEAD_BYTES
|
|
417
|
+
break if count.positive? && total + nxt > MAX_PUBLISH_BATCH_BYTES
|
|
418
|
+
|
|
419
|
+
total += nxt
|
|
420
|
+
count += 1
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
count
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# sends entries as one request, or one request per entry to an engine that
|
|
427
|
+
# predates PublishBatch, and settles each publish
|
|
428
|
+
def send_publishes(key, sent, entries)
|
|
429
|
+
unless @publish_batch_unsupported
|
|
430
|
+
begin
|
|
431
|
+
publish_ordered(key, entries)
|
|
432
|
+
sent.each { |p| p.result << [:ok] }
|
|
433
|
+
return
|
|
434
|
+
rescue StandardError => e
|
|
435
|
+
unless grpc_code(e) == GRPC::Core::StatusCodes::UNIMPLEMENTED
|
|
436
|
+
sent.each { |p| p.result << [:error, e] }
|
|
437
|
+
return
|
|
438
|
+
end
|
|
439
|
+
# nothing was stored, so the batch goes out again below
|
|
440
|
+
@publish_batch_unsupported = true
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# settled one by one: a failure partway leaves the publishes before it stored
|
|
445
|
+
sent.zip(entries).each do |p, entry|
|
|
446
|
+
publish_ordered(key, [entry])
|
|
447
|
+
p.result << [:ok]
|
|
448
|
+
rescue StandardError => e
|
|
449
|
+
p.result << [:error, e]
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def publish_ordered(key, entries, retry_gap: true)
|
|
454
|
+
namespace, topic = key
|
|
455
|
+
producer = @mutex.synchronize { @producers[key] ||= Producer.new(SecureRandom.uuid, 0) }
|
|
456
|
+
|
|
457
|
+
begin
|
|
458
|
+
if @publish_batch_unsupported && entries.length == 1
|
|
459
|
+
@stub.publish(
|
|
460
|
+
::V1::PublishStreamMessageRequest.new(
|
|
461
|
+
namespace: namespace,
|
|
462
|
+
topic: topic,
|
|
463
|
+
payload: entries[0].payload,
|
|
464
|
+
payload_ref: entries[0].payload_ref,
|
|
465
|
+
producer_id: producer.producer_id,
|
|
466
|
+
producer_seq: producer.seq,
|
|
467
|
+
),
|
|
468
|
+
metadata: @config.auth_metadata,
|
|
469
|
+
)
|
|
470
|
+
else
|
|
471
|
+
@stub.publish_batch(
|
|
472
|
+
::V1::PublishStreamMessagesRequest.new(
|
|
473
|
+
namespace: namespace,
|
|
474
|
+
topic: topic,
|
|
475
|
+
producer_id: producer.producer_id,
|
|
476
|
+
first_producer_seq: producer.seq,
|
|
477
|
+
entries: entries,
|
|
478
|
+
),
|
|
479
|
+
metadata: @config.auth_metadata,
|
|
480
|
+
)
|
|
481
|
+
end
|
|
482
|
+
rescue StandardError => e
|
|
483
|
+
code = grpc_code(e)
|
|
484
|
+
|
|
485
|
+
# it may still land, and a different payload under its seq would be dropped as a duplicate
|
|
486
|
+
@mutex.synchronize { @producers[key] = Producer.new(SecureRandom.uuid, 0) } unless REJECTED_BEFORE_STORING.include?(code)
|
|
487
|
+
|
|
488
|
+
# a gap stored nothing (e.g. the watermark passed cursor retention), so resend as the new producer
|
|
489
|
+
return publish_ordered(key, entries, retry_gap: false) if retry_gap && code == GRPC::Core::StatusCodes::FAILED_PRECONDITION
|
|
490
|
+
|
|
491
|
+
raise
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
producer.seq += entries.length
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
data/lib/hatchet/version.rb
CHANGED
data/lib/hatchet-sdk.rb
CHANGED
|
@@ -22,6 +22,7 @@ module Hatchet
|
|
|
22
22
|
require_relative "hatchet/features/rate_limits"
|
|
23
23
|
require_relative "hatchet/features/cron"
|
|
24
24
|
require_relative "hatchet/features/scheduled"
|
|
25
|
+
require_relative "hatchet/features/streams"
|
|
25
26
|
|
|
26
27
|
require_relative "hatchet/exceptions"
|
|
27
28
|
require_relative "hatchet/engine_version"
|
|
@@ -58,6 +59,8 @@ module Hatchet
|
|
|
58
59
|
require_relative "hatchet/contracts/v1/dispatcher_services_pb"
|
|
59
60
|
require_relative "hatchet/contracts/v1/workflows_pb"
|
|
60
61
|
require_relative "hatchet/contracts/v1/workflows_services_pb"
|
|
62
|
+
require_relative "hatchet/contracts/v1/streams_pb"
|
|
63
|
+
require_relative "hatchet/contracts/v1/streams_services_pb"
|
|
61
64
|
|
|
62
65
|
require_relative "hatchet/clients/grpc/dispatcher"
|
|
63
66
|
require_relative "hatchet/clients/grpc/admin"
|
|
@@ -198,6 +201,14 @@ module Hatchet
|
|
|
198
201
|
@scheduled ||= Hatchet::Features::Scheduled.new(rest_client, @config)
|
|
199
202
|
end
|
|
200
203
|
|
|
204
|
+
# The streams client publishes to and reads from durable topics. Unlike
|
|
205
|
+
# +runs.subscribe_to_stream+, topics are stored, independent of any run,
|
|
206
|
+
# and readers can resume from a cursor.
|
|
207
|
+
# @return [Hatchet::Features::Streams]
|
|
208
|
+
def streams
|
|
209
|
+
@streams ||= Hatchet::Features::Streams.new(rest_client, channel, @config)
|
|
210
|
+
end
|
|
211
|
+
|
|
201
212
|
# Define a Hatchet workflow, which can then declare tasks and be run,
|
|
202
213
|
# scheduled, and so on.
|
|
203
214
|
#
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Hatchet
|
|
2
|
+
module Features
|
|
3
|
+
class StreamEvent < Struct[untyped]
|
|
4
|
+
attr_accessor payload: String
|
|
5
|
+
attr_accessor cursor: String
|
|
6
|
+
attr_accessor created_at: Time?
|
|
7
|
+
|
|
8
|
+
def initialize: (payload: String, cursor: String, ?created_at: Time?) -> void
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class StreamTopicMetadata < Struct[untyped]
|
|
12
|
+
attr_accessor namespace: String
|
|
13
|
+
attr_accessor topic: String
|
|
14
|
+
attr_accessor tenant_id: String
|
|
15
|
+
attr_accessor message_count: Integer
|
|
16
|
+
attr_accessor latest_cursor: String?
|
|
17
|
+
attr_accessor last_published_at: Time?
|
|
18
|
+
|
|
19
|
+
def initialize: (namespace: String, topic: String, tenant_id: String, message_count: Integer, ?latest_cursor: String?, ?last_published_at: Time?) -> void
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class Streams
|
|
23
|
+
class Config < Struct[untyped]
|
|
24
|
+
attr_accessor payload_request_attempts: Integer
|
|
25
|
+
attr_accessor resubscribe_min_delay_seconds: Numeric
|
|
26
|
+
attr_accessor resubscribe_max_delay_seconds: Numeric
|
|
27
|
+
|
|
28
|
+
def initialize: (?payload_request_attempts: Integer, ?resubscribe_min_delay_seconds: Numeric, ?resubscribe_max_delay_seconds: Numeric) -> void
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize: (untyped rest_client, untyped channel, Hatchet::Config config, ?streams_config: Config) -> void
|
|
32
|
+
def publish: (String topic, String message, ?namespace: String, ?batch_timeout: Numeric?) -> void
|
|
33
|
+
def topic_metadata: (String topic, ?namespace: String) -> StreamTopicMetadata
|
|
34
|
+
def reset: (String topic, ?namespace: String, ?cursor: String?) -> void
|
|
35
|
+
def subscribe: (String topic, ?namespace: String, ?cursor: String?) { (StreamEvent) -> void } -> void
|
|
36
|
+
| (String topic, ?namespace: String, ?cursor: String?) -> Enumerator[StreamEvent, void]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/sig/hatchet-sdk.rbs
CHANGED
|
@@ -37,6 +37,7 @@ module Hatchet
|
|
|
37
37
|
def rate_limits: () -> Features::RateLimits
|
|
38
38
|
def cron: () -> Features::Cron
|
|
39
39
|
def scheduled: () -> Features::Scheduled
|
|
40
|
+
def streams: () -> Features::Streams
|
|
40
41
|
|
|
41
42
|
def workflow: (name: String, **untyped opts) -> Workflow
|
|
42
43
|
def task: (name: String, **untyped opts) { (Hash[String, untyped], Context) -> untyped } -> Task
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hatchet-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- gabriel ruttner
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -207,6 +207,7 @@ files:
|
|
|
207
207
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest.rb
|
|
208
208
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/api/api_token_api.rb
|
|
209
209
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/api/cel_api.rb
|
|
210
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/api/concurrency_api.rb
|
|
210
211
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/api/default_api.rb
|
|
211
212
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/api/durable_tasks_api.rb
|
|
212
213
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/api/event_api.rb
|
|
@@ -379,6 +380,13 @@ files:
|
|
|
379
380
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_cel_debug_request.rb
|
|
380
381
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_cel_debug_response.rb
|
|
381
382
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_cel_debug_response_status.rb
|
|
383
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_key.rb
|
|
384
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_key_list.rb
|
|
385
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_limit_strategy.rb
|
|
386
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_scope.rb
|
|
387
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_strategy.rb
|
|
388
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_strategy_list.rb
|
|
389
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_strategy_usage.rb
|
|
382
390
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_create_filter_request.rb
|
|
383
391
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_create_serverless_endpoint_request.rb
|
|
384
392
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_create_webhook_request.rb
|
|
@@ -409,6 +417,11 @@ files:
|
|
|
409
417
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_running_detail_count.rb
|
|
410
418
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_running_filter.rb
|
|
411
419
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint.rb
|
|
420
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_check_stage.rb
|
|
421
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event.rb
|
|
422
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event_details.rb
|
|
423
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event_kind.rb
|
|
424
|
+
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event_list.rb
|
|
412
425
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_kind.rb
|
|
413
426
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_list.rb
|
|
414
427
|
- lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_status.rb
|
|
@@ -512,6 +525,8 @@ files:
|
|
|
512
525
|
- lib/hatchet/contracts/v1/dispatcher_services_pb.rb
|
|
513
526
|
- lib/hatchet/contracts/v1/shared/condition_pb.rb
|
|
514
527
|
- lib/hatchet/contracts/v1/shared/trigger_pb.rb
|
|
528
|
+
- lib/hatchet/contracts/v1/streams_pb.rb
|
|
529
|
+
- lib/hatchet/contracts/v1/streams_services_pb.rb
|
|
515
530
|
- lib/hatchet/contracts/v1/workflows_pb.rb
|
|
516
531
|
- lib/hatchet/contracts/v1/workflows_services_pb.rb
|
|
517
532
|
- lib/hatchet/contracts/workflows/workflows_pb.rb
|
|
@@ -530,6 +545,7 @@ files:
|
|
|
530
545
|
- lib/hatchet/features/rate_limits.rb
|
|
531
546
|
- lib/hatchet/features/runs.rb
|
|
532
547
|
- lib/hatchet/features/scheduled.rb
|
|
548
|
+
- lib/hatchet/features/streams.rb
|
|
533
549
|
- lib/hatchet/features/tenant.rb
|
|
534
550
|
- lib/hatchet/features/workers.rb
|
|
535
551
|
- lib/hatchet/features/workflows.rb
|
|
@@ -580,6 +596,7 @@ files:
|
|
|
580
596
|
- sig/hatchet/features/rate_limits.rbs
|
|
581
597
|
- sig/hatchet/features/runs.rbs
|
|
582
598
|
- sig/hatchet/features/scheduled.rbs
|
|
599
|
+
- sig/hatchet/features/streams.rbs
|
|
583
600
|
- sig/hatchet/features/tenant.rbs
|
|
584
601
|
- sig/hatchet/features/workers.rbs
|
|
585
602
|
- sig/hatchet/features/workflows.rbs
|