cosmonats 0.4.1 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0bb0db4ef3d9dac8fe723ab7440ccc6b2fcd3d5c746efd2cb3dd4c5a28c2d6f6
4
- data.tar.gz: 06b65ca804dda9a66bc239feefb54cb9bdad2c23d63a0e514c97c33f602616f7
3
+ metadata.gz: 9167e7ccc35b55db0664e863eb04b007614dfc78efb7f203706b18e58ed2593f
4
+ data.tar.gz: 3654c4f5681440e79498b04328cd6565e646f6e9185c4705ca82aef517bf3342
5
5
  SHA512:
6
- metadata.gz: 0525004e2df9b1d37488e698a83ce5decf8681a673e6d66e720dc74788c78f7ebd16abd63510f830a62a8efbdad09bcc06278747e8423bce892bcaa9663f43ad
7
- data.tar.gz: b1b522586095c52f72631c80f1e6d8f22fc8853450515ecf49c91469bed0cd2269273cb0a2de95f46fd8c6e18d9a71e3ee7b5c9fad25cc60bf16cd2a62d2c1c2
6
+ metadata.gz: b92b7cce247a690370cfcf3f0941a30c63e35678b51228f784046ec9e04922d7632279d6f0d8f20641b168a06c5aa6f5198881b2060b65b4bad1e0abbffbf8c7
7
+ data.tar.gz: d005e21357274d3ef2a53601a0b7c9647d3fedef049a3ef64064b59ed1c3ee6f9c160c8304d05b5886da70c5b5227a2dabebfe3dce4e328ae62ad7bd37c4e2de
data/README.md CHANGED
@@ -71,6 +71,13 @@ bundle exec cosmo -C config/cosmo.yml -c 20 streams # Streams only
71
71
  - [Streams](#streams)
72
72
  - [Configuration](#configuration)
73
73
  - [Advanced Usage](#-advanced-usage)
74
+ - [Cron](#cron)
75
+ - [Priority Queues](#priority-queues)
76
+ - [Concurrency Limiting](#concurrency-limiting)
77
+ - [Custom Serializers](#custom-serializers)
78
+ - [Error Handling](#error-handling)
79
+ - [Testing](#testing)
80
+ - [Integrations](#integrations)
74
81
  - [CLI Reference](#-cli-reference)
75
82
  - [Deployment](#-deployment)
76
83
  - [Monitoring](#-monitoring)
@@ -138,6 +145,8 @@ nothing else to run.
138
145
  - **Automatic retries** — exponential backoff, configurable attempts
139
146
  - **Dead letter queue** — capture permanently failed jobs
140
147
  - **Job uniqueness** — prevent duplicate execution
148
+ - **Concurrency limits** — cap simultaneous executions per class or per key
149
+ - **Cron scheduling** — recurring jobs manageable live from the web UI
141
150
 
142
151
  ### 🌊 Stream Processing
143
152
  - **Real-time event streams** — process continuous data feeds
@@ -145,6 +154,7 @@ nothing else to run.
145
154
  - **Message replay** — reprocess from any point in time
146
155
  - **Consumer groups** — load-balanced across workers
147
156
  - **Custom serialization** — JSON, MessagePack, Protobuf
157
+ - **Pause / resume** — stop and restart a stream's processing without losing its position
148
158
 
149
159
 
150
160
  ## 📦 Installation
@@ -407,7 +417,48 @@ export COSMO_STREAMS_FETCH_TIMEOUT=0.1
407
417
 
408
418
  ## 🔧 Advanced Usage
409
419
 
410
- **Priority Queues:**
420
+ ### Cron
421
+
422
+ Recurring jobs, without a separate scheduler process. A schedule is just a message parked in the
423
+ job's own NATS stream (requires NATS Server 2.14+) — NATS fires it on the cron expression, and it
424
+ lands back in the stream as a regular job. Deploy it once; whatever's in NATS is exactly what runs
425
+ and exactly what shows up in the web UI's **Crons** tab, where each entry can be inspected, run
426
+ immediately, or deleted.
427
+
428
+ Declare schedules right in `config/cosmo.yml`:
429
+
430
+ ```yaml
431
+ setup:
432
+ cron:
433
+ daily_report:
434
+ class: ReportJob
435
+ schedule: "@daily" # @-shortcuts are passed straight through to NATS
436
+ stream: default
437
+ weekday_digest:
438
+ class: ReportJob
439
+ schedule: "0 9 * * 1-5" # 6-field NATS cron (seconds first); 5-field UNIX cron is auto-normalized
440
+ stream: default
441
+ args: ["daily"]
442
+ timezone: America/New_York # optional, cron expressions only
443
+ ```
444
+
445
+ `cosmo -C config/cosmo.yml -S` syncs it — whatever's in the file is exactly what ends up scheduled in NATS, same as streams.
446
+
447
+ Prefer to manage schedules at runtime instead? The same operations are available from Ruby:
448
+
449
+ ```ruby
450
+ Cosmo::API::Cron.instance.upsert!(
451
+ class_name: "ReportJob", stream: "default", schedule: "0 9 * * 1-5",
452
+ args: ["daily"], timezone: "America/New_York", name: "weekday_report"
453
+ )
454
+
455
+ Cosmo::API::Cron.instance.all # every schedule currently deployed
456
+ Cosmo::API::Cron.instance.run_now!("cosmo.cron.default.report_job.weekday_report") # bypass the timer
457
+ Cosmo::API::Cron.instance.delete!("cosmo.cron.default.report_job.weekday_report") # stop future firings
458
+ ```
459
+
460
+ ### Priority Queues
461
+
411
462
  ```ruby
412
463
  class UrgentJob
413
464
  include Cosmo::Job
@@ -415,7 +466,30 @@ class UrgentJob
415
466
  end
416
467
  ```
417
468
 
418
- **Custom Serializers:**
469
+ ### Concurrency Limiting
470
+
471
+ ```ruby
472
+ class ThirdPartyApiJob
473
+ include Cosmo::Job
474
+ # At most 3 instances of this job run at once, cluster-wide.
475
+ # Jobs that lose the race are NAK'd with a delay equal to `duration`
476
+ # so they aren't redelivered until a slot is guaranteed free.
477
+ options limit: { duration: 30, concurrency: 3 }
478
+ end
479
+
480
+ class PerAccountSyncJob
481
+ include Cosmo::Job
482
+ # Scope the cap per key instead of class-wide — e.g. one concurrent sync per account.
483
+ options limit: { duration: 30, concurrency: { to: 1, key: ->(account_id) { account_id } } }
484
+
485
+ def perform(account_id)
486
+ Account.find(account_id).sync!
487
+ end
488
+ end
489
+ ```
490
+
491
+ ### Custom Serializers
492
+
419
493
  ```ruby
420
494
  module MessagePackSerializer
421
495
  def self.serialize(data) = MessagePack.pack(data)
@@ -428,7 +502,8 @@ class FastStream
428
502
  end
429
503
  ```
430
504
 
431
- **Error Handling:**
505
+ ### Error Handling
506
+
432
507
  ```ruby
433
508
  class ResilientJob
434
509
  include Cosmo::Job
@@ -446,7 +521,8 @@ class ResilientJob
446
521
  end
447
522
  ```
448
523
 
449
- **Testing:**
524
+ ### Testing
525
+
450
526
  ```ruby
451
527
  # Synchronous — no NATS needed
452
528
  SendEmailJob.perform_sync(123, "test")
@@ -457,6 +533,40 @@ assert_kind_of String, jid
457
533
  ```
458
534
 
459
535
 
536
+ ### Integrations
537
+
538
+ **ActiveJob:**
539
+ ```ruby
540
+ # config/application.rb
541
+ config.active_job.queue_adapter = :cosmonats
542
+ ```
543
+ The ActiveJob queue name maps directly to a Cosmo stream. Use `cosmo_options` for anything
544
+ Cosmo-specific — retries, DLQ behavior, or overriding the target stream:
545
+ ```ruby
546
+ class ReportJob < ApplicationJob
547
+ cosmo_options retry: 5, dead: false, stream: :critical
548
+
549
+ def perform(report_id)
550
+ Report.find(report_id).generate!
551
+ end
552
+ end
553
+ ```
554
+ Inside a Rails app this is wired up automatically by the bundled Railtie — it registers the
555
+ adapter and loads `config/cosmo.yml` if present. Outside Rails:
556
+ ```ruby
557
+ require "cosmo/active_job"
558
+ ActiveJob::Base.queue_adapter = Cosmo::ActiveJobAdapter::Adapter.new
559
+ ```
560
+
561
+ **Sentry:**
562
+ ```ruby
563
+ require "cosmo/sentry/auto"
564
+ ```
565
+ Wraps every job execution in a Sentry transaction (`queue.cosmonats`) and captures unhandled
566
+ exceptions with the job's id, stream, subject, and retry count attached as context — no other
567
+ setup beyond having `sentry-ruby` initialized.
568
+
569
+
460
570
  ## 🖥️ CLI Reference
461
571
 
462
572
  ```bash
@@ -542,6 +652,12 @@ sudo systemctl enable cosmo && sudo systemctl start cosmo
542
652
 
543
653
  ## 📊 Monitoring
544
654
 
655
+ **Web UI** — mount `Cosmo::Web` (see [Installation](#-installation)) for a live, htmx-powered dashboard:
656
+ - **Jobs** — enqueued, scheduled, busy, and dead views, with per-job retry and delete
657
+ - **Streams** — per-stream state (messages, bytes, consumers) with pause/resume
658
+ - **Crons** — every schedule deployed in NATS, with run-now and delete
659
+ - Summary counters (processed / failed / busy / enqueued / retries / scheduled / dead) backed by a NATS KV counter, no separate metrics store needed
660
+
545
661
  **Structured logs:**
546
662
  ```
547
663
  2026-01-23T10:15:30.123Z INFO pid=12345 tid=abc jid=def: start
data/lib/cosmo/api/kv.rb CHANGED
@@ -8,39 +8,15 @@ module Cosmo
8
8
  def initialize(name, options = nil)
9
9
  @name = name
10
10
  @options = Hash(options)
11
- @kv = Client.instance.kv(@name, **@options)
11
+ @kv = client.kv(@name, **@options)
12
12
  end
13
13
 
14
- def set(key, value, ttl: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
14
+ def set(key, value, ttl: nil)
15
15
  return kv.put(key, value.to_s) unless ttl
16
16
 
17
- # Pass ttl: (seconds) to set a per-message expiry.
17
+ # Pass ttl: (seconds) to set per-message expiry.
18
18
  # Raises `NATS::KeyValue::KeyWrongLastSequenceError` when the key is live.
19
- begin
20
- value = value.to_s
21
- put = lambda do |last_seq:|
22
- headers = { "Nats-Expected-Last-Subject-Sequence" => last_seq.to_s, "Nats-TTL" => "#{ttl.to_i}s" }
23
- Client.instance.js.publish("$KV.#{@name}.#{key}", value, header: headers)
24
- rescue NATS::JetStream::Error::APIError => e
25
- raise NATS::KeyValue::KeyWrongLastSequenceError, e.description if e.err_code == 10_071
26
-
27
- raise
28
- end
29
-
30
- put.call(last_seq: 0)
31
- kv.send(:_get, key) # fetch the created entry to get its revision
32
- rescue NATS::KeyValue::KeyWrongLastSequenceError
33
- # `kv.get` converts KeyDeletedError → KeyNotFoundError, hiding tombstone info.
34
- # Use private _get instead — it raises KeyDeletedError with the entry's revision
35
- begin
36
- kv.send(:_get, key)
37
- rescue NATS::KeyValue::KeyDeletedError => e
38
- put.call(last_seq: e.entry.revision)
39
- return kv.send(:_get, key)
40
- end
41
-
42
- raise
43
- end
19
+ publish_cas(key, value.to_s, ttl, last_seq: 0).seq
44
20
  end
45
21
 
46
22
  def get(key)
@@ -49,6 +25,9 @@ module Cosmo
49
25
  # nop
50
26
  end
51
27
 
28
+ # Writes a KV-Operation tombstone. On a ttl-bearing bucket this leaves the
29
+ # subject occupied, so a subsequent #set(ttl:) CAS with last_seq: 0 will
30
+ # keep failing -- use #erase on those buckets instead.
52
31
  def delete(key)
53
32
  kv.delete(key)
54
33
  end
@@ -66,12 +45,21 @@ module Cosmo
66
45
  results
67
46
  end
68
47
 
48
+ # Writes a KV-Operation tombstone (same issue as #delete on ttl buckets).
69
49
  def purge(key)
70
50
  kv.purge(key)
71
51
  end
72
52
 
53
+ # Removes +key+ leaving no trace at all -- unlike #delete/#purge, which
54
+ # write a KV-Operation tombstone message. Mirrors how per-message
55
+ # Nats-TTL expiry removes a key, so callers never have to distinguish
56
+ # "deleted" from "TTL-expired" on read.
57
+ def erase(key)
58
+ client.purge("KV_#{@name}", "$KV.#{@name}.#{key}")
59
+ end
60
+
73
61
  def clean
74
- Client.instance.purge("KV_#{@name}", ">")
62
+ client.purge("KV_#{@name}", ">")
75
63
  end
76
64
 
77
65
  def count
@@ -80,6 +68,26 @@ module Cosmo
80
68
  0
81
69
  end
82
70
  alias size count
71
+
72
+ private
73
+
74
+ # CAS = Compare-And-Swap: publish +value+ with a per-message Nats-TTL,
75
+ # but only if the subject's current last sequence matches +last_seq+
76
+ # (sent as the Nats-Expected-Last-Subject-Sequence header). Raises
77
+ # NATS::KeyValue::KeyWrongLastSequenceError if it doesn't match --
78
+ # e.g. last_seq: 0 means "only publish if nothing exists here yet".
79
+ def publish_cas(key, value, ttl, last_seq:)
80
+ headers = { "Nats-Expected-Last-Subject-Sequence" => last_seq.to_s, "Nats-TTL" => "#{ttl.to_i}s" }
81
+ client.js.publish("$KV.#{@name}.#{key}", value, header: headers)
82
+ rescue NATS::JetStream::Error::APIError => e
83
+ raise NATS::KeyValue::KeyWrongLastSequenceError, e.description if e.err_code == 10_071
84
+
85
+ raise
86
+ end
87
+
88
+ def client
89
+ Client.instance
90
+ end
83
91
  end
84
92
  end
85
93
  end
@@ -9,8 +9,9 @@ module Cosmo
9
9
  #
10
10
  # Acquiring a slot is a single atomic `set` (CAS with last-revision=0).
11
11
  # Only one worker can win a given slot; losers try the next number.
12
- # When a job finishes the slot is deleted; if the worker crashes NATS
13
- # expires it automatically via the per-message Nats-TTL header.
12
+ # When a job finishes, the slot is erased; if the worker crashes, NATS
13
+ # expires it automatically via the per-message Nats-TTL header. Both
14
+ # paths leave the slot equally empty -- no tombstone, no delete marker.
14
15
  class Limit
15
16
  BUCKET = "cosmo_jobs_limits"
16
17
 
@@ -40,11 +41,14 @@ module Cosmo
40
41
  nil # all slots occupied
41
42
  end
42
43
 
43
- # Release a previously acquired slot.
44
+ # Release a previously acquired slot. Erases the slot entirely (no
45
+ # tombstone left behind) so a released slot looks identical to one
46
+ # reclaimed by Nats-TTL expiry -- callers never have to special-case a
47
+ # delete marker.
44
48
  def release(slot)
45
- @kv.delete(slot)
49
+ @kv.erase(slot)
46
50
  rescue NATS::Error
47
- # best effort — slot TTL will reclaim it if delete fails
51
+ # best effort — slot TTL will reclaim it if erase fails
48
52
  end
49
53
  end
50
54
  end
@@ -107,7 +107,7 @@ module Cosmo
107
107
 
108
108
  # Tries to acquire a concurrency slot for the job.
109
109
  # Returns the slot key (String) on success, or false if all slots are
110
- # taken (message is NAK'd with a delay equal to +duration+ before returning).
110
+ # taken (a message is NAK'd with a delay of +retry_in+ before returning
111
111
  def acquire_concurrency_slot(worker_class, message, data)
112
112
  options = worker_class.concurrency_options
113
113
  key = worker_class.concurrency_key(data[:args])
@@ -115,7 +115,7 @@ module Cosmo
115
115
  slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
116
116
  return slot if slot
117
117
 
118
- message.nak(delay: options[:duration] * Config::NANO)
118
+ message.nak(delay: options[:retry_in] * Config::NANO)
119
119
  Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
120
120
  false
121
121
  rescue NATS::Error => e
data/lib/cosmo/job.rb CHANGED
@@ -19,14 +19,19 @@ module Cosmo
19
19
  # limit: { duration: 30 }
20
20
  # limit: { duration: 30, concurrency: 3 }
21
21
  # limit: { duration: 30, concurrency: { to: 3, key: ->(id) { id } } }
22
+ # limit: { duration: 30, concurrency: 3, retry_in: 5 }
22
23
  #
23
24
  # @option config [Integer] :"limit[:duration]" hard execution timeout in seconds. The job thread is
24
25
  # killed after this many seconds and counts as a failed attempt (retried with exponential backoff,
25
26
  # moved to DLQ after retries exhausted).
26
27
  # @option config [Integer, Hash] :"limit[:concurrency]" caps how many instances run at once across all
27
- # workers. Jobs that cannot acquire a slot are NAK'd with a delay equal to +duration+ so they are not
28
- # re-delivered until the slot is guaranteed free. Requires +duration+.
28
+ # workers. Jobs that cannot acquire a slot are NAK'd (see +retry_in+) so they are not re-delivered until
29
+ # the slot is likely free. Requires +duration+.
29
30
  # Pass an Integer for a class-wide cap, or <tt>{ to: N, key: ->(args) {} }</tt> to scope per key.
31
+ # @option config [Integer] :"limit[:retry_in]" seconds to wait before NATS redelivers a job that was
32
+ # NAK'd for lack of a concurrency slot (default: half of +duration+). Counts against the same delivery
33
+ # counter as any other retry -- a job stuck behind the concurrency limit for enough consecutive
34
+ # attempts is dropped/DLQ'd exactly like one that keeps failing outright.
30
35
  def options(**config)
31
36
  if config[:limit] && config.dig(:limit, :concurrency) && !config.dig(:limit, :duration).to_i.positive?
32
37
  raise ArgumentError, "limit: duration is required when concurrency is set"
@@ -41,15 +46,17 @@ module Cosmo
41
46
  end
42
47
 
43
48
  # Returns a normalized concurrency config hash, or +nil+ when not configured.
44
- # Always contains +:limit+, +:key+, and +:duration+.
49
+ # Always contains +:limit+, +:key+, +:duration+, and +:retry_in+.
45
50
  def concurrency_options
46
51
  value = default_options.dig(:limit, :concurrency)
47
- duration = default_options.dig(:limit, :duration).to_i
48
52
  return unless value
49
53
 
54
+ duration = default_options.dig(:limit, :duration).to_i
55
+ retry_in = default_options.dig(:limit, :retry_in)&.to_i || (duration / 2)
56
+
50
57
  case value
51
- when Integer then { limit: value, key: nil, duration: duration }
52
- when Hash then { limit: value.fetch(:to), key: value[:key], duration: duration }
58
+ when Integer then { limit: value, key: nil, duration: duration, retry_in: retry_in }
59
+ when Hash then { limit: value.fetch(:to), key: value[:key], duration: duration, retry_in: retry_in }
53
60
  end
54
61
  end
55
62
 
data/lib/cosmo/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cosmo
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.2"
5
5
  end
@@ -489,6 +489,13 @@ time {
489
489
  cursor: default;
490
490
  pointer-events: none;
491
491
  }
492
+ .pagination .btn-primary.btn-disabled {
493
+ opacity: 1;
494
+ }
495
+ .pagination-gap {
496
+ color: var(--color-text-light);
497
+ padding: 0 var(--space-1-2);
498
+ }
492
499
 
493
500
  /* ── Actions ───────────────────────────────────────────────────────────── */
494
501
  .actions-form { display: flex; flex-direction: column; gap: var(--space-1-2); }
@@ -67,6 +67,21 @@ module Cosmo
67
67
  Rack::Utils.escape(value.to_s)
68
68
  end
69
69
 
70
+ # Build the list of page numbers to render around the current page, with
71
+ # `:gap` markers where numbers are skipped.
72
+ # pages(5, 20) # => [1, :gap, 3, 4, 5, 6, 7, :gap, 20]
73
+ def pages(page, total_pages, window: 2)
74
+ return [] if total_pages <= 1
75
+
76
+ previous = nil
77
+ candidates = ([1, total_pages] + ((page - window)..(page + window)).to_a).grep(1..total_pages).uniq.sort
78
+ candidates.each_with_object([]) do |p, result|
79
+ pagination_fill_gap(result, previous, p)
80
+ result << p
81
+ previous = p
82
+ end
83
+ end
84
+
70
85
  def current_page?(path)
71
86
  request_path = @request.path_info
72
87
  request_path = "/" if request_path.empty?
@@ -85,6 +100,15 @@ module Cosmo
85
100
  referrer_path = "/" if referrer_path.empty?
86
101
  referrer_path == path
87
102
  end
103
+
104
+ private
105
+
106
+ def pagination_fill_gap(result, previous, page)
107
+ return unless previous
108
+
109
+ result << (previous + 1) if page - previous == 2
110
+ result << :gap if page - previous > 2
111
+ end
88
112
  end
89
113
  end
90
114
  end
@@ -64,21 +64,36 @@
64
64
  <% if @total_pages > 1 -%>
65
65
  <div class="pagination">
66
66
  <% if @page > 1 -%>
67
- <a hx-get="<%= url_for('/jobs/_enqueued', stream_name: @stream_name, page: @page - 1, limit: @limit) %>"
67
+ <a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: @page - 1, limit: @limit) %>"
68
68
  hx-target="#enqueued-poller"
69
69
  hx-swap="outerHTML"
70
+ hx-push-url="true"
70
71
  hx-indicator="#global-spinner"
71
72
  class="btn">&#x2190; Prev</a>
72
73
  <% else -%>
73
74
  <span class="btn btn-disabled">&#x2190; Prev</span>
74
75
  <% end -%>
75
76
 
76
- <span class="text-muted">Page <%= @page %> of <%= @total_pages %></span>
77
+ <% pages(@page, @total_pages).each do |p| -%>
78
+ <% if p == :gap -%>
79
+ <span class="pagination-gap">&hellip;</span>
80
+ <% elsif p == @page -%>
81
+ <span class="btn btn-primary btn-disabled"><%= p %></span>
82
+ <% else -%>
83
+ <a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: p, limit: @limit) %>"
84
+ hx-target="#enqueued-poller"
85
+ hx-swap="outerHTML"
86
+ hx-push-url="true"
87
+ hx-indicator="#global-spinner"
88
+ class="btn"><%= p %></a>
89
+ <% end -%>
90
+ <% end -%>
77
91
 
78
92
  <% if @page < @total_pages -%>
79
- <a hx-get="<%= url_for('/jobs/_enqueued', stream_name: @stream_name, page: @page + 1, limit: @limit) %>"
93
+ <a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: @page + 1, limit: @limit) %>"
80
94
  hx-target="#enqueued-poller"
81
95
  hx-swap="outerHTML"
96
+ hx-push-url="true"
82
97
  hx-indicator="#global-spinner"
83
98
  class="btn">Next &#x2192;</a>
84
99
  <% else -%>
data/sig/cosmo/api/kv.rbs CHANGED
@@ -19,10 +19,18 @@ module Cosmo
19
19
 
20
20
  def purge: (::String | ::Integer key) -> untyped
21
21
 
22
+ def erase: (::String | ::Integer key) -> ::Integer?
23
+
22
24
  def clean: () -> untyped
23
25
 
24
26
  def count: () -> ::Integer
25
27
  alias size count
28
+
29
+ private
30
+
31
+ def publish_cas: (::String | ::Integer key, ::String value, ::Integer? ttl, last_seq: ::Integer) -> untyped
32
+
33
+ def client: () -> Client
26
34
  end
27
35
  end
28
36
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cosmonats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Vorotilin