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.
- checksums.yaml +4 -4
- data/README.md +77 -5
- data/lib/cosmo/active_job/executor.rb +9 -0
- data/lib/cosmo/active_job/options.rb +8 -5
- data/lib/cosmo/api/batch.rb +84 -0
- data/lib/cosmo/api/counter.rb +17 -6
- data/lib/cosmo/api/cron/entry.rb +1 -1
- data/lib/cosmo/api/cron.rb +1 -1
- data/lib/cosmo/api/kv.rb +7 -0
- data/lib/cosmo/api.rb +1 -0
- data/lib/cosmo/batch/callback.rb +17 -0
- data/lib/cosmo/batch/dispatcher.rb +86 -0
- data/lib/cosmo/batch.rb +164 -0
- data/lib/cosmo/cli.rb +31 -25
- data/lib/cosmo/config.rb +10 -2
- data/lib/cosmo/job/data.rb +14 -8
- data/lib/cosmo/job/processor.rb +78 -23
- data/lib/cosmo/job.rb +46 -9
- data/lib/cosmo/processor.rb +7 -7
- data/lib/cosmo/utils/duration.rb +33 -0
- data/lib/cosmo/utils.rb +1 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +9 -1
- data/lib/cosmo/web/controllers/batches.rb +21 -0
- data/lib/cosmo/web/controllers/jobs.rb +7 -4
- data/lib/cosmo/web/views/batches/_table.erb +47 -0
- data/lib/cosmo/web/views/batches/index.erb +10 -0
- data/lib/cosmo/web/views/jobs/_enqueued.erb +37 -35
- data/lib/cosmo/web/views/jobs/_tabs.erb +2 -0
- data/lib/cosmo/web/views/layout.erb +1 -1
- data/lib/cosmo/web.rb +3 -0
- data/lib/cosmo.rb +1 -0
- data/sig/cosmo/active_job/executor.rbs +2 -0
- data/sig/cosmo/api/batch.rbs +43 -0
- data/sig/cosmo/batch/callback.rbs +9 -0
- data/sig/cosmo/batch/dispatcher.rbs +23 -0
- data/sig/cosmo/batch.rbs +49 -0
- data/sig/cosmo/config.rbs +2 -0
- data/sig/cosmo/job/data.rbs +3 -1
- data/sig/cosmo/job/processor.rbs +17 -1
- data/sig/cosmo/job.rbs +4 -0
- data/sig/cosmo/processor.rbs +3 -3
- data/sig/cosmo/utils/duration.rbs +11 -0
- metadata +28 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d13ca716127cde6f5ee26e9a859a91f68d8f0e91a4e43c48b4ebceaae1a2dfef
|
|
4
|
+
data.tar.gz: 2a421516f48ae13cadec5470d3a3113f28f403a979319e36667866f2367b201c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
208
|
-
max_deliver:
|
|
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:
|
|
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,
|
|
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:
|
|
17
|
-
# dead:
|
|
18
|
-
# stream:
|
|
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
|
data/lib/cosmo/api/counter.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
data/lib/cosmo/api/cron/entry.rb
CHANGED
data/lib/cosmo/api/cron.rb
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
data/lib/cosmo/batch.rb
ADDED
|
@@ -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
|