cosmonats 0.4.3 → 0.5.0

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -5
  3. data/lib/cosmo/active_job/executor.rb +9 -0
  4. data/lib/cosmo/active_job/options.rb +8 -5
  5. data/lib/cosmo/api/batch.rb +84 -0
  6. data/lib/cosmo/api/counter.rb +17 -6
  7. data/lib/cosmo/api/cron/entry.rb +1 -1
  8. data/lib/cosmo/api/cron.rb +1 -1
  9. data/lib/cosmo/api/kv.rb +7 -0
  10. data/lib/cosmo/api.rb +1 -0
  11. data/lib/cosmo/batch/callback.rb +17 -0
  12. data/lib/cosmo/batch/dispatcher.rb +86 -0
  13. data/lib/cosmo/batch.rb +164 -0
  14. data/lib/cosmo/cli.rb +31 -25
  15. data/lib/cosmo/config.rb +10 -2
  16. data/lib/cosmo/job/data.rb +14 -8
  17. data/lib/cosmo/job/processor.rb +78 -23
  18. data/lib/cosmo/job.rb +46 -9
  19. data/lib/cosmo/processor.rb +7 -7
  20. data/lib/cosmo/utils/duration.rb +33 -0
  21. data/lib/cosmo/utils.rb +1 -0
  22. data/lib/cosmo/version.rb +1 -1
  23. data/lib/cosmo/web/assets/app.css +9 -1
  24. data/lib/cosmo/web/controllers/batches.rb +21 -0
  25. data/lib/cosmo/web/controllers/jobs.rb +7 -4
  26. data/lib/cosmo/web/views/batches/_table.erb +47 -0
  27. data/lib/cosmo/web/views/batches/index.erb +10 -0
  28. data/lib/cosmo/web/views/jobs/_enqueued.erb +37 -35
  29. data/lib/cosmo/web/views/jobs/_tabs.erb +2 -0
  30. data/lib/cosmo/web/views/layout.erb +1 -1
  31. data/lib/cosmo/web.rb +3 -0
  32. data/lib/cosmo.rb +1 -0
  33. data/sig/cosmo/active_job/executor.rbs +2 -0
  34. data/sig/cosmo/api/batch.rbs +43 -0
  35. data/sig/cosmo/batch/callback.rbs +9 -0
  36. data/sig/cosmo/batch/dispatcher.rbs +23 -0
  37. data/sig/cosmo/batch.rbs +49 -0
  38. data/sig/cosmo/config.rbs +2 -0
  39. data/sig/cosmo/job/data.rbs +3 -1
  40. data/sig/cosmo/job/processor.rbs +17 -1
  41. data/sig/cosmo/job.rbs +4 -0
  42. data/sig/cosmo/processor.rbs +3 -3
  43. data/sig/cosmo/utils/duration.rbs +11 -0
  44. metadata +28 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5704f0d1aa99b76acfecee7b986cab37a9bc8863832c868edc28a3ffeeb7f418
4
- data.tar.gz: 9128efdd2e934778ea6d5e6412cbcd0d8424743a881b0a56edb665eefe8463b8
3
+ metadata.gz: d13ca716127cde6f5ee26e9a859a91f68d8f0e91a4e43c48b4ebceaae1a2dfef
4
+ data.tar.gz: 2a421516f48ae13cadec5470d3a3113f28f403a979319e36667866f2367b201c
5
5
  SHA512:
6
- metadata.gz: 8a64d21839c7a7f18da7a47eb10a40209126d350b5bfc3778d75f110b4bc05034e558f87566f1140fd71e0a0b44f1019f49832f1e6f66460da06fdcc1ba8e7c1
7
- data.tar.gz: 21c1231e93af54a7ca68fc021415657dd9bb332893779ec936dc140e947055fff4e2c2a8fa8962d59a34ab3b6599ef9aa67b81b6dcf19d9b190df756f260b6f2
6
+ metadata.gz: c0aee10ad2470135778ef9d16732f041ac67426b3d5aeaeeaf899c473b4ec5af5018c0d1d7f3aeb862dea21d41741e85e9f84070f3c802a95014f48c65c95eac
7
+ data.tar.gz: fe1a8cb37bbd553b1bc69eaf35ac0134bf155c222d9f49b539286509884a194c8fc830c05bdb35052e51ebf83055fdf7f030a2d697b6c0912ba1884eacd46628
data/README.md CHANGED
@@ -73,6 +73,7 @@ bundle exec cosmo -C config/cosmo.yml -c 20 streams # Streams only
73
73
  - [Advanced Usage](#-advanced-usage)
74
74
  - [Cron](#cron)
75
75
  - [Priority Queues](#priority-queues)
76
+ - [Batches](#batches)
76
77
  - [Concurrency Limiting](#concurrency-limiting)
77
78
  - [Custom Serializers](#custom-serializers)
78
79
  - [Error Handling](#error-handling)
@@ -147,6 +148,7 @@ nothing else to run.
147
148
  - **Job uniqueness** — prevent duplicate execution
148
149
  - **Concurrency limits** — cap simultaneous executions per class or per key
149
150
  - **Cron scheduling** — recurring jobs manageable live from the web UI
151
+ - **Batches** — group jobs and fire a callback once the whole group finishes, including nested batches
150
152
 
151
153
  ### 🌊 Stream Processing
152
154
  - **Real-time event streams** — process continuous data feeds
@@ -204,8 +206,8 @@ concurrency: 5 # Number of worker threads
204
206
  consumers: # Declare consumer groups for streams, things that pull messages and process them
205
207
  jobs: # Consumer configs for jobs (or streams)
206
208
  default: # Stream name
207
- ack_policy: explicit # Acknowledgment required for each message, can be explicit, none, or all
208
- max_deliver: 10 # Max retry attempts before sending to a dead stream
209
+ ack_policy: explicit # Acknowledgment required for each message can be explicit, none, or all
210
+ max_deliver: 30 # Max retry attempts before sending to a dead stream. Safety ceiling only, keep it above every job class's own retry
209
211
  max_ack_pending: 10 # Max messages waiting for ack, if exceeded, the server will stop delivering new messages until some are acked
210
212
  ack_wait: 15 # Seconds to wait for ack before redelivering
211
213
  subject: jobs.%{name}.> # Subject pattern for this consumer, %{name} replaced with stream name, becomes `jobs.default.>`
@@ -328,6 +330,7 @@ The `>` wildcard matches everything after that prefix. Think of subjects as topi
328
330
  timeout: 25 # Shutdown timeout in seconds
329
331
  concurrency: &concurrency 1 # Number of worker threads
330
332
  max_retries: &max_retries 3 # Default max retries
333
+ batch_expiry: 259200 # Seconds before a Batch's tracking data expires (default: 3 days)
331
334
 
332
335
  stream_config: &stream_config
333
336
  storage: file # storage type (file or memory)
@@ -340,7 +343,7 @@ stream_config: &stream_config
340
343
 
341
344
  consumer_config: &consumer_config
342
345
  ack_policy: explicit # ack policy (explicit, none, all), each individual message must be acknowledged
343
- max_deliver: 10 # maximum number of times a message will be delivered before it's considered failed
346
+ max_deliver: 30 # maximum number of times a message will be delivered before it's considered failed. keep it above every job class's own retry; a job that still exceeds it is capped and dead-lettered early with a warning
344
347
  max_ack_pending: 20 # maximum number of messages with pending ack for this consumer
345
348
  ack_wait: 60 # time in seconds to wait for an ack before redelivering the message
346
349
  subject: jobs.%{name}.> # subject pattern for consumer, %{name} will be replaced with stream name
@@ -466,6 +469,58 @@ class UrgentJob
466
469
  end
467
470
  ```
468
471
 
472
+ ### Batches
473
+
474
+ Group jobs together and fire a callback once every one of them has finished. The registered class
475
+ is plain Ruby — it implements `on_complete(status, opts)` / `on_success(status, opts)`, not
476
+ `perform` — and runs on the job worker pool, not inline on whichever thread finalized the batch.
477
+ `status` is `{ bid:, total:, succeeded:, failed: }` and `opts` is whatever you passed to `#on`.
478
+
479
+ ```ruby
480
+ batch = Cosmo::Batch.new
481
+ batch.jobs do
482
+ ImportJob.perform_async(1)
483
+ ImportJob.perform_async(2)
484
+ end
485
+ batch.on(:complete, NotifyUser, user_id: 1) # fires once every job has finished, pass or fail
486
+ batch.on(:success, NotifyUser, user_id: 1) # fires only if none of them failed
487
+
488
+ class NotifyUser
489
+ def on_complete(status, opts)
490
+ UserMailer.batch_done(opts[:user_id], status[:succeeded], status[:total]).deliver_later
491
+ end
492
+
493
+ def on_success(status, opts)
494
+ UserMailer.batch_succeeded(opts[:user_id]).deliver_later
495
+ end
496
+ end
497
+ ```
498
+
499
+ - `#jobs` is the only place membership is tracked — jobs enqueued outside the block aren't part of
500
+ the batch. Call it at least once (an empty block is fine) to close the batch.
501
+ - `:complete` always fires once every job is done. `:success` fires only if none were dead-lettered
502
+ or dropped after exhausting retries. Both can be registered before or after `#jobs` — a callback
503
+ registered after the batch has already finished still fires.
504
+
505
+ **Nested batches** — a running job can spawn its own sub-batch, which counts as one pending unit
506
+ of its parent and propagates any failure upward as a single failure unit:
507
+
508
+ ```ruby
509
+ class ImportJob
510
+ include Cosmo::Job
511
+
512
+ def perform(account_id)
513
+ sub_batch = Cosmo::Batch.new(parent: batch_id) # batch_id is this job's own batch, if any
514
+ sub_batch.jobs { SyncRecordJob.perform_async(account_id) }
515
+ end
516
+ end
517
+ ```
518
+ - Batch tracking data (pending counts, callbacks, results) expires automatically after
519
+ `Config[:batch_expiry]` seconds (default: 3 days).
520
+ - Open and finished batches — with pending/succeeded/failed counts — are listed live in the web UI's **Batches** tab.
521
+ - Only `Cosmo::Job`-based jobs are tracked; the ActiveJob adapter doesn't currently participate in
522
+ batches (see [`docs/active_job.md`](docs/active_job.md)).
523
+
469
524
  ### Concurrency Limiting
470
525
 
471
526
  ```ruby
@@ -521,6 +576,22 @@ class ResilientJob
521
576
  end
522
577
  ```
523
578
 
579
+ By default, a failed job is redelivered after `attempt**4 + 15` seconds. Override that per job class
580
+ with `retry_in`, given the 1-based attempt count and the exception that was raised:
581
+ ```ruby
582
+ class ThrottledApiJob
583
+ include Cosmo::Job
584
+ options retry: 5, retry_in: ->(count, exception) { exception.is_a?(RateLimitedError) ? 60 : count * 10 }
585
+
586
+ def perform(...)
587
+ # ...
588
+ end
589
+ end
590
+ ```
591
+ If the proc returns something non-numeric/non-positive, or raises, the default backoff is used
592
+ instead. Note: if this job class also sets `limit: { concurrency: ... }` (see above), `count`
593
+ includes deliveries that were turned away for lack of a free slot, not just failed attempts.
594
+
524
595
  ### Testing
525
596
 
526
597
  ```ruby
@@ -541,10 +612,10 @@ assert_kind_of String, jid
541
612
  config.active_job.queue_adapter = :cosmonats
542
613
  ```
543
614
  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:
615
+ Cosmo-specific — retries, DLQ behavior, overriding the target stream, or a custom retry delay:
545
616
  ```ruby
546
617
  class ReportJob < ApplicationJob
547
- cosmo_options retry: 5, dead: false, stream: :critical
618
+ cosmo_options retry: 5, dead: false, stream: :critical, retry_in: ->(count, exception) { count * 10 }
548
619
 
549
620
  def perform(report_id)
550
621
  Report.find(report_id).generate!
@@ -656,6 +727,7 @@ sudo systemctl enable cosmo && sudo systemctl start cosmo
656
727
  - **Jobs** — enqueued, scheduled, busy, and dead views, with per-job retry and delete
657
728
  - **Streams** — per-stream state (messages, bytes, consumers) with pause/resume
658
729
  - **Crons** — every schedule deployed in NATS, with run-now and delete
730
+ - **Batches** — open and finished batches with pending/succeeded/failed counts
659
731
  - Summary counters (processed / failed / busy / enqueued / retries / scheduled / dead) backed by a NATS KV counter, no separate metrics store needed
660
732
 
661
733
  **Structured logs:**
@@ -8,6 +8,15 @@ module Cosmo
8
8
 
9
9
  options stream: :default
10
10
 
11
+ # Resolves +retry_in+ from the underlying ActiveJob class (declared via +cosmo_options+),
12
+ # since the processor only ever sees +Executor+ as the worker class for ActiveJob-dispatched jobs.
13
+ def self.retry_in(data)
14
+ job_class = Utils::String.safe_constantize(data.dig(:args, 0, :job_class))
15
+ return super unless job_class.respond_to?(:get_cosmo_options)
16
+
17
+ job_class.get_cosmo_options[:retry_in] || super
18
+ end
19
+
11
20
  def perform(job_data)
12
21
  ::ActiveJob::Base.execute(Utils::Hash.stringify_keys(job_data))
13
22
  end
@@ -5,7 +5,7 @@ module Cosmo
5
5
  # Adds +cosmo_options+ to ActiveJob classes.
6
6
  #
7
7
  # class MyJob < ApplicationJob
8
- # cosmo_options retry: 5, dead: false
8
+ # cosmo_options retry: 5, dead: false, retry_in: ->(count, exception) { count * 30 }
9
9
  #
10
10
  # def perform(user_id)
11
11
  # # ...
@@ -13,11 +13,14 @@ module Cosmo
13
13
  # end
14
14
  #
15
15
  # Options mirror those accepted by +Cosmo::Job+:
16
- # retry: [Integer] Number of retries before giving up (default: 3)
17
- # dead: [Boolean] Move to DLQ when retries exhausted? (default: true)
18
- # stream: [Symbol] Override the NATS stream (default: derived from queue_name)
16
+ # retry: [Integer, Boolean] Number of retries before giving up (default: 3). +false+ means 0 (no retries)
17
+ # dead: [Boolean] Move to DLQ when retries exhausted? (default: true)
18
+ # stream: [Symbol] Override the NATS stream (default: derived from queue_name)
19
+ # retry_in: [Proc] <tt>->(count, exception) { }</tt> computing seconds to wait before redelivering a
20
+ # failed job (+count+ is the 1-based delivery attempt that just failed). Falls back to the
21
+ # default backoff if unset, non-numeric/non-positive, or raising.
19
22
  module Options
20
- VALID_OPTIONS = %i[retry dead stream].freeze
23
+ VALID_OPTIONS = %i[retry dead stream retry_in].freeze
21
24
 
22
25
  def self.included(base)
23
26
  base.extend(ClassMethods)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ # Read-model over Cosmo::Batch's KV/counter state, for the status API and Web UI.
6
+ class Batch
7
+ LIMIT = 25
8
+
9
+ def self.all(limit: LIMIT)
10
+ kv.keys("*.meta", limit: limit).map { |key| new(key.delete_suffix(".meta")) }
11
+ end
12
+
13
+ def self.kv
14
+ Cosmo::Batch.kv
15
+ end
16
+
17
+ def self.counter
18
+ Cosmo::Batch.counter
19
+ end
20
+
21
+ attr_reader :bid
22
+
23
+ def initialize(bid)
24
+ @bid = bid
25
+ end
26
+
27
+ def meta
28
+ Utils::Json.parse(kv.get("#{bid}.meta")&.value) || {}
29
+ end
30
+
31
+ def parent_id
32
+ meta[:parent_id]
33
+ end
34
+
35
+ def created_at
36
+ meta[:created_at]
37
+ end
38
+
39
+ def ready?
40
+ !!kv.get("#{bid}.ready")
41
+ end
42
+
43
+ # { total:, pending:, succeeded:, failed: } -- read from the +ready+
44
+ # snapshot once finalized (counters are reset by then), or live off the
45
+ # counters (with +pending+ still moving) while the batch is still open.
46
+ def stats
47
+ entry = kv.get("#{bid}.ready")
48
+ return Utils::Json.parse(entry.value).merge(pending: 0) if entry
49
+
50
+ live_stats
51
+ end
52
+
53
+ def callback(event)
54
+ entry = kv.get("#{bid}.callback.#{event}")
55
+ Utils::Json.parse(entry&.value)
56
+ end
57
+
58
+ def callbacks
59
+ { success: callback(:success), complete: callback(:complete) }
60
+ end
61
+
62
+ def fired?(event)
63
+ !!kv.get("#{bid}.fired.#{event}")
64
+ end
65
+
66
+ private
67
+
68
+ def live_stats
69
+ total = counter.get("#{bid}.total")
70
+ failed = counter.get("#{bid}.failed")
71
+ pending = counter.get("#{bid}.pending")
72
+ { total: total, pending: pending, succeeded: total - failed - pending, failed: failed }
73
+ end
74
+
75
+ def kv
76
+ self.class.kv
77
+ end
78
+
79
+ def counter
80
+ self.class.counter
81
+ end
82
+ end
83
+ end
84
+ end
@@ -21,19 +21,20 @@ module Cosmo
21
21
  increment(:failed)
22
22
  end
23
23
 
24
- def increment(key, by: 1)
25
- publish(key, "+#{by}")
24
+ def increment(key, by: 1, msg_id: nil)
25
+ publish(key, "+#{by}", msg_id: msg_id)
26
26
  end
27
27
  alias incr increment
28
28
 
29
- def decrement(key, by: 1)
30
- publish(key, "-#{by}")
29
+ def decrement(key, by: 1, msg_id: nil)
30
+ publish(key, "-#{by}", msg_id: msg_id)
31
31
  end
32
32
  alias decr decrement
33
33
 
34
34
  def reset(key)
35
35
  client.purge(STREAM_NAME, subject(key))
36
36
  end
37
+ alias purge reset
37
38
 
38
39
  def get(key)
39
40
  raw = client.get_message(STREAM_NAME, direct: true, subject: subject(key))
@@ -44,11 +45,21 @@ module Cosmo
44
45
 
45
46
  private
46
47
 
47
- def publish(key, value)
48
+ # @param msg_id [String, nil] when given, sent as Nats-Msg-Id so a redelivered
49
+ # caller (e.g. a job whose ack was lost and retried) collapses onto the
50
+ # stream's duplicate_window instead of double-applying the +/- delta.
51
+ # @return [Integer, nil] the resulting counter value, or +nil+ when this exact
52
+ # +msg_id+ was already applied (a deduped publish's PubAck carries no +val+
53
+ # at all -- coercing that to 0 would look identical to "counter is now 0",
54
+ # so callers must treat +nil+ as "no new information" rather than a real value).
55
+ def publish(key, value, msg_id: nil)
48
56
  rescued = nil
57
+ headers = { "Nats-Incr" => value }
58
+ headers["Nats-Msg-Id"] = msg_id if msg_id
49
59
 
50
60
  begin
51
- client.publish(subject(key), "", header: { "Nats-Incr" => value }).val.to_i
61
+ ack = client.publish(subject(key), "", header: headers)
62
+ ack.val.to_i unless ack.duplicate
52
63
  rescue NATS::JetStream::Error::NoStreamResponse
53
64
  raise if rescued
54
65
 
@@ -84,7 +84,7 @@ module Cosmo
84
84
  jid: SecureRandom.hex(12),
85
85
  class: @class_name,
86
86
  args: @args,
87
- retry: ::Cosmo::Job::Data::DEFAULTS[:retry],
87
+ retry: ::Cosmo::Job::Data.default_retry,
88
88
  dead: ::Cosmo::Job::Data::DEFAULTS[:dead]
89
89
  })
90
90
  end
@@ -65,7 +65,7 @@ module Cosmo
65
65
  jid: SecureRandom.hex(12),
66
66
  class: body[:class],
67
67
  args: body[:args] || [],
68
- retry: body[:retry] || Job::Data::DEFAULTS[:retry],
68
+ retry: body[:retry] || Job::Data.default_retry,
69
69
  dead: body[:dead].nil? ? Job::Data::DEFAULTS[:dead] : body[:dead]
70
70
  })
71
71
  client.publish(target, payload, stream: stream_name)
data/lib/cosmo/api/kv.rb CHANGED
@@ -25,6 +25,13 @@ module Cosmo
25
25
  # nop
26
26
  end
27
27
 
28
+ # Creates +key+ if it does not already exist -- a plain CAS-if-absent with
29
+ # no per-message TTL involved. Raises NATS::KeyValue::KeyWrongLastSequenceError
30
+ # when the key is already live, so callers can use it as a fire-once gate.
31
+ def create(key, value)
32
+ kv.create(key, value.to_s)
33
+ end
34
+
28
35
  # Writes a KV-Operation tombstone. On a ttl-bearing bucket this leaves the
29
36
  # subject occupied, so a subsequent #set(ttl:) CAS with last_seq: 0 will
30
37
  # keep failing -- use #erase on those buckets instead.
data/lib/cosmo/api.rb CHANGED
@@ -5,6 +5,7 @@ require "cosmo/api/counter"
5
5
  require "cosmo/api/kv"
6
6
  require "cosmo/api/stats"
7
7
  require "cosmo/api/cron"
8
+ require "cosmo/api/batch"
8
9
 
9
10
  module Cosmo
10
11
  module API
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ class Batch
5
+ # The actual job that gets enqueued when a batch finishes. It looks up
6
+ # the plain Ruby class you passed to Batch#on and calls on_success or
7
+ # on_complete on it.
8
+ class Callback
9
+ include Cosmo::Job
10
+
11
+ def perform(class_name, event, status, opts)
12
+ klass = Utils::String.safe_constantize(class_name)
13
+ klass&.new&.public_send("on_#{event}", status, opts)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ class Batch
5
+ # The engine behind Batch.notify: decides when a batch is done and
6
+ # fires its callbacks.
7
+ module Dispatcher
8
+ private
9
+
10
+ # Decrements pending. If this decrement just brought it to zero,
11
+ # finalize the batch. This never touches total or failed - that's what
12
+ # lets #jobs release its placeholder slot without messing up the stats
13
+ # we report back.
14
+ def release_pending(bid, msg_id: nil)
15
+ pending = counter.decrement("#{bid}.pending", msg_id: msg_id)
16
+ finalize(bid) if pending.to_i.zero?
17
+ end
18
+
19
+ # Runs once per batch. Has its own "already done" guard, separate from
20
+ # try_fire's, because calling #jobs again on an already-finished batch
21
+ # would otherwise re-run this and double-report to the parent batch.
22
+ def finalize(bid)
23
+ kv.create("#{bid}.finalized", "1")
24
+
25
+ total = counter.get("#{bid}.total")
26
+ failed = counter.get("#{bid}.failed")
27
+ stats = { total: total, succeeded: total - failed, failed: failed }
28
+ kv.set("#{bid}.ready", Utils::Json.dump(stats))
29
+ purge_counters(bid)
30
+
31
+ EVENTS.each { |event| try_fire(bid, event, stats: stats) }
32
+ parent_propagate(bid, failed: failed)
33
+ rescue NATS::KeyValue::KeyWrongLastSequenceError
34
+ # already finalized - this is a stray completion from a reused Batch, ignore it
35
+ end
36
+
37
+ def purge_counters(bid)
38
+ %w[total pending failed].each { |key| counter.purge("#{bid}.#{key}") }
39
+ end
40
+
41
+ # Tells the parent batch "one of your jobs (me) is done", counting any
42
+ # failure in this batch as one failure for the parent. Same shape as
43
+ # #notify - the child's bid just stands in for a jid here.
44
+ def parent_propagate(bid, failed:)
45
+ meta = Utils::Json.parse(kv.get("#{bid}.meta")&.value)
46
+ parent_id = meta && meta[:parent_id]
47
+ return unless parent_id
48
+
49
+ notify(parent_id, bid, success: failed.zero?)
50
+ end
51
+
52
+ # Adds this batch as one job of the parent batch.
53
+ def link(parent_id)
54
+ counter.increment("#{parent_id}.total")
55
+ counter.increment("#{parent_id}.pending")
56
+ end
57
+
58
+ # Fires the callback for +event+, if one is registered and the batch is
59
+ # ready. Safe to call any number of times from either #on or a job
60
+ # finishing - it only actually fires once.
61
+ def try_fire(bid, event, stats: nil)
62
+ callback_entry = kv.get("#{bid}.callback.#{event}")
63
+ stats ||= Utils::Json.parse(kv.get("#{bid}.ready")&.value)
64
+ return unless fireable?(callback_entry, event, stats)
65
+
66
+ kv.create("#{bid}.fired.#{event}", "1")
67
+ dispatch_callback(callback_entry, bid, event, stats)
68
+ rescue NATS::KeyValue::KeyWrongLastSequenceError
69
+ # someone else already fired this event, do nothing
70
+ end
71
+
72
+ def fireable?(callback_entry, event, stats)
73
+ return false unless callback_entry && stats
74
+ return false if event == :success && stats[:failed].to_i.positive?
75
+
76
+ true
77
+ end
78
+
79
+ def dispatch_callback(callback_entry, bid, event, stats)
80
+ callback = Utils::Json.parse(callback_entry.value)
81
+ status = stats.merge(bid: bid)
82
+ Callback.perform_async(callback[:class], event, status, callback[:opts] || {})
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "cosmo/batch/callback"
5
+ require "cosmo/batch/dispatcher"
6
+
7
+ module Cosmo
8
+ # Groups jobs together and runs a callback once they've all finished.
9
+ #
10
+ # batch = Cosmo::Batch.new
11
+ # batch.jobs { MyJob.perform_async(1); MyJob.perform_async(2) }
12
+ # batch.on(:complete, MyCallback, user_id: 1)
13
+ #
14
+ # :complete fires once every job is done, pass or fail.
15
+ # :success only fires if none of them ended up dead-lettered or dropped.
16
+ #
17
+ # The callback is a plain Ruby class, not a job. It just needs an
18
+ # on_complete/on_success(status, options) method:
19
+ #
20
+ # class MyCallback
21
+ # def on_complete(status, options)
22
+ # Notifier.notify(options[:user_id], "#{status[:succeeded]}/#{status[:total]} done")
23
+ # end
24
+ # end
25
+ #
26
+ # It still runs on the worker pool, not on the thread that finished the
27
+ # batch - it's wrapped in an internal Batch::Callback job.
28
+ #
29
+ # status - { bid:, total:, succeeded:, failed: }
30
+ # options - arbitrary custom arguments passed to #on
31
+ #
32
+ # Only jobs enqueued inside a #jobs block count. Nested batches must be created explicitly,
33
+ # by passing the parent's batch_id:
34
+ #
35
+ # Cosmo::Batch.new(parent: batch_id)
36
+ #
37
+ # A nested batch counts as one job of its parent. If anything fails in it,
38
+ # the failure is propagated to the parent counter either. The parent succeeds
39
+ # when everything succeeds. Always call #jobs at least once, even with nothing
40
+ # in it - a batch that never gets a #jobs call never closes and never fires.
41
+ #
42
+ # == How this is actually stored in NATS
43
+ #
44
+ # Every batch has an id (bid, a random hex string). State lives
45
+ # in two places, both keyed by that bid:
46
+ #
47
+ # 1. Three atomic counters (via API::Counter, namespace "batch", backed by
48
+ # the shared _cosmostats stream):
49
+ #
50
+ # <bid>.total - how many jobs have ever joined this batch
51
+ # <bid>.pending - how many of them are still running
52
+ # <bid>.failed - how many ended up dead-lettered or dropped
53
+ #
54
+ # Each increment/decrement is one atomic NATS publish, and the reply
55
+ # tells you the resulting value right away - no separate read needed.
56
+ # Whichever decrement of +pending+ happens to bring it down to exactly
57
+ # 0 is the one that finalizes the batch (see #release_pending). Since
58
+ # it's a single atomic counter, exactly one decrement can ever be "the
59
+ # one that hits zero", so the batch can't finish twice or too early.
60
+ #
61
+ # 2. A KV bucket (BUCKET, ttl'd so old batches clean themselves up):
62
+ #
63
+ # <bid>.meta - { parent_id, created_at }, written once at creation
64
+ # <bid>.callback.success - { class, opts } from #on(:success, ...)
65
+ # <bid>.callback.complete - { class, opts } from #on(:complete, ...)
66
+ # <bid>.ready - { total, succeeded, failed }, written once
67
+ # by #finalize, right after reading the counters
68
+ # <bid>.fired.success - exists once the :success callback has run
69
+ # <bid>.fired.complete - exists once the :complete callback has run
70
+ # <bid>.finalized - exists once #finalize has run for this bid
71
+ #
72
+ # The fired.* and finalized keys are write-once: kv.create raises if the
73
+ # key already exists. That's what stops the same callback (or the same
74
+ # finalize) from running twice when two things race to trigger it - a
75
+ # job finishing at the same moment someone calls #on, for example.
76
+ class Batch
77
+ extend Dispatcher
78
+
79
+ BUCKET = "cosmo_jobs_batches"
80
+ DEFAULT_EXPIRY = "3d"
81
+ EVENTS = %i[success complete].freeze
82
+
83
+ def self.current
84
+ Thread.current[:cosmo_batch]
85
+ end
86
+
87
+ # Called by Job::Processor when a job is done for good - acked, or
88
+ # dead-lettered/dropped. Never call this for a retry. JID is used to dedupe: if the same
89
+ # job's ack gets lost, and it's redelivered, we don't want to count it twice.
90
+ def self.notify(bid, jid, success:)
91
+ counter.increment("#{bid}.failed", msg_id: "batch.#{bid}.#{jid}.failed") unless success
92
+ release_pending(bid, msg_id: "batch.#{bid}.#{jid}.pending")
93
+ end
94
+
95
+ def self.counter
96
+ @counter ||= API::Counter.new("batch")
97
+ end
98
+
99
+ def self.kv
100
+ @kv ||= API::KV.new(BUCKET, ttl: Utils::Duration.parse(Config[:batch_expiry] || DEFAULT_EXPIRY))
101
+ end
102
+
103
+ attr_reader :bid, :parent_id
104
+
105
+ def initialize(parent: nil)
106
+ @bid = SecureRandom.hex(8)
107
+ @parent_id = parent
108
+ kv.set("#{@bid}.meta", Utils::Json.dump({ parent_id: @parent_id, created_at: Time.now.to_i }))
109
+ link(@parent_id) if @parent_id
110
+ end
111
+
112
+ # Every perform_async/perform_at/perform_in called inside this block
113
+ # joins the batch. Holds a placeholder "pending" slot for the whole
114
+ # block, so the batch can't finish while you're still adding jobs, even
115
+ # if the first one completes instantly. The placeholder never counts
116
+ # toward total, so it doesn't skew the stats. Safe to call more than
117
+ # once. If the block raises, we still release the placeholder (so the
118
+ # batch isn't stuck) and let the error propagate as normal.
119
+ def jobs
120
+ previous = Thread.current[:cosmo_batch]
121
+ Thread.current[:cosmo_batch] = self
122
+ counter.increment("#{bid}.pending")
123
+ begin
124
+ yield
125
+ ensure
126
+ Thread.current[:cosmo_batch] = previous
127
+ release_pending
128
+ end
129
+ end
130
+
131
+ # Registers a callback for +event+ (:success or :complete). Can be
132
+ # called before or after #jobs - if the batch already finished, it
133
+ # fires right away.
134
+ def on(event, klass, **opts)
135
+ raise ArgumentError, "event must be :success or :complete" unless EVENTS.include?(event)
136
+
137
+ kv.set("#{bid}.callback.#{event}", Utils::Json.dump({ class: klass.name, opts: opts }))
138
+ try_fire(event)
139
+ end
140
+
141
+ # Called by Job#perform for each job added inside #jobs.
142
+ def register_job!
143
+ counter.increment("#{bid}.total")
144
+ counter.increment("#{bid}.pending")
145
+ end
146
+
147
+ # Undoes #register_job! when the publish itself failed, so the job
148
+ # never really joined the batch. Unlike a real completion, this also
149
+ # reverts total, not just pending.
150
+ def rollback_job!
151
+ counter.decrement("#{bid}.total")
152
+ release_pending
153
+ end
154
+
155
+ private
156
+
157
+ # Bridges instance methods to the private class methods Dispatcher adds to Batch
158
+ def counter = self.class.counter
159
+ def kv = self.class.kv
160
+ def link(parent_id) = self.class.send(:link, parent_id)
161
+ def release_pending = self.class.send(:release_pending, bid)
162
+ def try_fire(event) = self.class.send(:try_fire, bid, event)
163
+ end
164
+ end