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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +1 -0
  4. data/lib/hatchet/clients/rest/.openapi-generator/FILES +13 -0
  5. data/lib/hatchet/clients/rest/README.md +15 -0
  6. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/api/concurrency_api.rb +188 -0
  7. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/api/serverless_api.rb +77 -0
  8. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/api_token.rb +28 -1
  9. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/create_api_token_request.rb +11 -1
  10. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/feature_flag_id.rb +2 -1
  11. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/tenant_entitlements.rb +30 -4
  12. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_key.rb +326 -0
  13. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_key_list.rb +275 -0
  14. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_limit_strategy.rb +44 -0
  15. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_scope.rb +41 -0
  16. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_strategy.rb +676 -0
  17. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_strategy_list.rb +239 -0
  18. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_concurrency_strategy_usage.rb +307 -0
  19. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_create_serverless_endpoint_request.rb +1 -1
  20. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_check_stage.rb +42 -0
  21. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event.rb +363 -0
  22. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event_details.rb +358 -0
  23. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event_kind.rb +44 -0
  24. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_event_list.rb +239 -0
  25. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_serverless_endpoint_kind.rb +2 -1
  26. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest/models/v1_stream_topic.rb +31 -4
  27. data/lib/hatchet/clients/rest/lib/hatchet-sdk-rest.rb +13 -0
  28. data/lib/hatchet/contracts/v1/streams_pb.rb +28 -0
  29. data/lib/hatchet/contracts/v1/streams_services_pb.rb +26 -0
  30. data/lib/hatchet/features/streams.rb +498 -0
  31. data/lib/hatchet/version.rb +1 -1
  32. data/lib/hatchet-sdk.rb +11 -0
  33. data/sig/hatchet/features/streams.rbs +39 -0
  34. data/sig/hatchet-sdk.rbs +1 -0
  35. 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hatchet
4
- VERSION = "0.11.2"
4
+ VERSION = "0.12.1"
5
5
  end
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.11.2
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-10-27 00:00:00.000000000 Z
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