milvaion-worker 1.0.0 → 1.1.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 +68 -3
- data/lib/milvaion/kafka.rb +299 -0
- data/lib/milvaion/kafka_transport.rb +649 -0
- data/lib/milvaion/options.rb +26 -5
- data/lib/milvaion/processor.rb +77 -10
- data/lib/milvaion/worker/version.rb +1 -1
- data/lib/milvaion/worker.rb +27 -6
- data/milvaion-worker.gemspec +2 -2
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1033916061253c1a73d22565a6f32702505cd8f623a219e07954e9107a685fcf
|
|
4
|
+
data.tar.gz: b89bafd296550ad1f2b25465af782d87be5b4979b18dabfa601ee918770ee8e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 685939781a8206aae1350bc5a316979d07378b4980c009504bd90e0fbc9e13fe5d25722be5fa415fa7ff77fbfc7d7e250fa977807c656839bf69f3772337a313
|
|
7
|
+
data.tar.gz: ecf1afed8544856409d617e37020ef5f627e809256c35df35bb70916724535026c6063b11126d3f0b5e13df0545822ba5ed1172c6c24a2f42f689222079392b4
|
data/README.md
CHANGED
|
@@ -9,10 +9,10 @@ gem install milvaion-worker
|
|
|
9
9
|
Or add it to your Gemfile:
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
|
-
gem "milvaion-worker", "~> 1.
|
|
12
|
+
gem "milvaion-worker", "~> 1.1"
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
It requires Ruby 3.1 or later, and connects to the same RabbitMQ and Redis instances as your Milvaion API. It uses [bunny](https://github.com/ruby-amqp/bunny) for RabbitMQ and [redis-rb](https://github.com/redis/redis-rb) for Redis.
|
|
15
|
+
It requires Ruby 3.1 or later, and connects to the same RabbitMQ and Redis instances as your Milvaion API. It uses [bunny](https://github.com/ruby-amqp/bunny) for RabbitMQ and [redis-rb](https://github.com/redis/redis-rb) for Redis. Apache Kafka can replace RabbitMQ; see [Kafka](#kafka).
|
|
16
16
|
|
|
17
17
|
## Quick start
|
|
18
18
|
|
|
@@ -146,6 +146,8 @@ Pass these to `Milvaion::Worker.new(...)` or `Milvaion::WorkerOptions.new(...)`:
|
|
|
146
146
|
| `version` | `1.0.0` | Shown in the dashboard |
|
|
147
147
|
| `instance_id` | `{worker_id}-{8 hex}` | Must be unique per process |
|
|
148
148
|
| `logger` | a `Logger` on `$stdout` at INFO | |
|
|
149
|
+
| `transport` | `rabbitmq` | `rabbitmq` or `kafka`, see [Kafka](#kafka) |
|
|
150
|
+
| `kafka` | none | Kafka settings, a Hash, see [Kafka](#kafka) |
|
|
149
151
|
|
|
150
152
|
For containers, `Milvaion::Worker.new(Milvaion::WorkerOptions.from_env)` reads the following variables. Keyword arguments to `from_env` take precedence.
|
|
151
153
|
|
|
@@ -157,6 +159,62 @@ For containers, `Milvaion::Worker.new(Milvaion::WorkerOptions.from_env)` reads t
|
|
|
157
159
|
- `MILVAION_MAX_PARALLEL_JOBS`
|
|
158
160
|
- `MILVAION_EXECUTION_TIMEOUT_SECONDS`
|
|
159
161
|
- `MILVAION_HEARTBEAT_INTERVAL_SECONDS`
|
|
162
|
+
- `MILVAION_TRANSPORT` and the `MILVAION_KAFKA_*` variables listed under [Kafka](#kafka)
|
|
163
|
+
|
|
164
|
+
## Kafka
|
|
165
|
+
|
|
166
|
+
RabbitMQ is the default broker. A deployment can use Apache Kafka instead. The API and every worker of a deployment must use the same broker, so switch the API to Kafka too (`MilvaionConfig:Transport:Type`). Redis still carries cancellation requests, as on RabbitMQ.
|
|
167
|
+
|
|
168
|
+
The Kafka client is the [rdkafka](https://github.com/karafka/rdkafka-ruby) gem (librdkafka). It is not a dependency of `milvaion-worker`, so add it to your Gemfile next to the SDK. The worker loads it only when the transport is `kafka`, and `start` raises an error that says so if it is missing.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
gem "milvaion-worker", "~> 1.1"
|
|
172
|
+
gem "rdkafka", ">= 0.19"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
rdkafka ships precompiled builds for Linux (glibc and musl) and macOS; on other platforms it compiles librdkafka when installed, which needs a C toolchain. Ruby 3.1 gets rdkafka 0.22, newer Rubies get later releases.
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
worker = Milvaion::Worker.new(
|
|
179
|
+
worker_id: "mail-worker",
|
|
180
|
+
transport: "kafka",
|
|
181
|
+
kafka: {
|
|
182
|
+
brokers: ["kafka-1:9092", "kafka-2:9092"],
|
|
183
|
+
topic_prefix: "prod." # must match the API's
|
|
184
|
+
},
|
|
185
|
+
redis_url: "redis://localhost:6379"
|
|
186
|
+
)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Handlers do not change. Jobs, outcomes, retries, logs and cancellation behave as on RabbitMQ.
|
|
190
|
+
|
|
191
|
+
| `kafka:` key | Default | Description |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| `brokers` | `localhost:9092` | Bootstrap servers, an Array or a comma separated String |
|
|
194
|
+
| `tls` | `false` | Turns TLS on. Setting any of the three files below turns it on too. |
|
|
195
|
+
| `tls_ca_file` | system roots | PEM bundle used to verify the brokers |
|
|
196
|
+
| `tls_cert_file`, `tls_key_file` | none | PEM client certificate and key for mutual TLS |
|
|
197
|
+
| `sasl_mechanism` | none | `plain`, `scram-sha-256` or `scram-sha-512`, with `sasl_username` and `sasl_password` |
|
|
198
|
+
| `topic_prefix` | empty | Put in front of every topic and consumer group name. Only `a-z A-Z 0-9 . _ -`. Must match the API's. |
|
|
199
|
+
| `auto_create_topics` | `true` | Create the worker's topics, and the shared topics it writes to, at startup |
|
|
200
|
+
| `num_partitions` | 12 | Partitions of the topics the worker creates |
|
|
201
|
+
| `replication_factor` | -1 | Replication of the topics the worker creates. -1 uses the broker's default. |
|
|
202
|
+
| `max_in_flight_per_partition` | worker `max_parallel_jobs` | Jobs of one partition that may run at the same time |
|
|
203
|
+
|
|
204
|
+
`WorkerOptions.from_env` reads them from `MILVAION_TRANSPORT` (`kafka`), `MILVAION_KAFKA_BOOTSTRAP_SERVERS` (or `KAFKA_BOOTSTRAP_SERVERS`), `MILVAION_KAFKA_TLS` (`true`), `MILVAION_KAFKA_TLS_CA_FILE`, `MILVAION_KAFKA_TLS_CERT_FILE`, `MILVAION_KAFKA_TLS_KEY_FILE`, `MILVAION_KAFKA_SASL_MECHANISM`, `MILVAION_KAFKA_SASL_USERNAME`, `MILVAION_KAFKA_SASL_PASSWORD`, `MILVAION_KAFKA_TOPIC_PREFIX`, `MILVAION_KAFKA_AUTO_CREATE_TOPICS` (`false` disables it), `MILVAION_KAFKA_NUM_PARTITIONS` (or `MILVAION_KAFKA_PARTITIONS`), `MILVAION_KAFKA_REPLICATION_FACTOR` and `MILVAION_KAFKA_MAX_IN_FLIGHT_PER_PARTITION`. Keys of a `kafka:` Hash passed to `from_env` take precedence.
|
|
205
|
+
|
|
206
|
+
How it works on Kafka:
|
|
207
|
+
|
|
208
|
+
- **Topics.** The worker reads `milvaion.jobs.{worker}` and `milvaion.jobs.{worker}.retry`, where `{worker}` is the worker id in lower case with every character outside `a-z 0-9 . _ -` replaced by `_`. It writes to `milvaion.status`, `milvaion.logs`, `milvaion.heartbeats`, `milvaion.registrations` and `milvaion.dlq`. All names get the topic prefix. It picks the handler by the job name in the record, so routing patterns do not decide where a job goes.
|
|
209
|
+
- **Instances.** Every instance of a worker joins the consumer group `milvaion.worker.{worker}`. Partitions are shared between the instances, so a topic with 12 partitions serves at most 12 instances. Raise `num_partitions` before you scale out further.
|
|
210
|
+
- **Parallelism.** An instance runs up to `max_parallel_jobs` jobs at once, also several from one partition, up to `max_in_flight_per_partition`. The per job `max_parallel_jobs` applies to RabbitMQ only. When the instance is full, it pauses the partitions and keeps polling, so it stays in the group.
|
|
211
|
+
- **Commits.** A job record is committed only after its outcome has been reported. Kafka commits a position, not a single record, so a partition is committed up to its oldest unfinished job. If an instance crashes, every record after that one runs again, including jobs that had already finished. Make handlers idempotent; `ctx.occurrence_id` helps with that.
|
|
212
|
+
- **Retries.** A failed attempt with retries left is written to the retry topic with a time before which it must not run (`base × 2^n`). The instance slot is free during the wait. The instance that reads the retry holds it until it is due, and the occurrence keeps sending job heartbeats meanwhile.
|
|
213
|
+
- **Dead letters.** A job that fails for good goes to `milvaion.dlq` as on RabbitMQ. A record that is not a valid job is forwarded there unchanged, with the headers `x-original-topic` and `x-dlq-reason`.
|
|
214
|
+
- **Shutdown.** `stop` stops taking records, waits up to `shutdown_timeout`, writes the jobs that are still running back to their topic, commits and leaves the group.
|
|
215
|
+
- **Priority** is best effort: records of a partition are read in the order the API wrote them.
|
|
216
|
+
|
|
217
|
+
The full wire contract is in [KAFKA-TRANSPORT.md](https://github.com/Milvasoft/milvaion/blob/master/docs/githubdocs/KAFKA-TRANSPORT.md).
|
|
160
218
|
|
|
161
219
|
## Reliability
|
|
162
220
|
|
|
@@ -170,7 +228,7 @@ For containers, `Milvaion::Worker.new(Milvaion::WorkerOptions.from_env)` reads t
|
|
|
170
228
|
|
|
171
229
|
## How it works
|
|
172
230
|
|
|
173
|
-
The SDK implements the language-neutral [worker protocol](https://github.com/Milvasoft/milvaion/blob/master/docs/githubdocs/WORKER-PROTOCOL.md): the RabbitMQ queues and message formats the .NET SDK uses.
|
|
231
|
+
The SDK implements the language-neutral [worker protocol](https://github.com/Milvasoft/milvaion/blob/master/docs/githubdocs/WORKER-PROTOCOL.md): the RabbitMQ queues and message formats the .NET SDK uses. On Kafka it follows [KAFKA-TRANSPORT.md](https://github.com/Milvasoft/milvaion/blob/master/docs/githubdocs/KAFKA-TRANSPORT.md).
|
|
174
232
|
|
|
175
233
|
## Development
|
|
176
234
|
|
|
@@ -188,3 +246,10 @@ docker run -d --name rabbit -p 5672:5672 rabbitmq:4
|
|
|
188
246
|
docker run -d --name redis -p 6379:6379 redis:7
|
|
189
247
|
MILVAION_TEST_RABBITMQ_URL=amqp://guest:guest@localhost:5672 MILVAION_TEST_REDIS_URL=redis://localhost:6379 bundle exec rspec
|
|
190
248
|
```
|
|
249
|
+
|
|
250
|
+
The Kafka integration specs run against a real broker and are skipped unless `MILVAION_KAFKA_BROKERS` is set. The Gemfile adds rdkafka for development and tests.
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
docker run -d --name kafka -p 9092:9092 apache/kafka:3.9.0
|
|
254
|
+
MILVAION_KAFKA_BROKERS=localhost:9092 bundle exec rspec
|
|
255
|
+
```
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Milvaion
|
|
4
|
+
# Kafka wire contract shared with the Milvaion API and the other SDKs.
|
|
5
|
+
# See docs/githubdocs/KAFKA-TRANSPORT.md in the repository. Nothing in this file needs the
|
|
6
|
+
# rdkafka gem; the transport itself (kafka_transport.rb) loads it only when it is used.
|
|
7
|
+
module Kafka
|
|
8
|
+
HEADER_OCCURRENCE_ID = "OccurrenceId"
|
|
9
|
+
HEADER_CORRELATION_ID = "CorrelationId"
|
|
10
|
+
HEADER_RETRY_COUNT = "x-retry-count"
|
|
11
|
+
HEADER_MAX_RETRIES = "MaxRetries"
|
|
12
|
+
HEADER_NOT_BEFORE = "x-not-before"
|
|
13
|
+
HEADER_ORIGINAL_TOPIC = "x-original-topic"
|
|
14
|
+
HEADER_DLQ_REASON = "x-dlq-reason"
|
|
15
|
+
|
|
16
|
+
TOPIC_PREFIX_PATTERN = /\A[A-Za-z0-9._-]*\z/.freeze
|
|
17
|
+
SASL_MECHANISMS = { "plain" => "PLAIN", "scramsha256" => "SCRAM-SHA-256", "scramsha512" => "SCRAM-SHA-512" }.freeze
|
|
18
|
+
|
|
19
|
+
# Topic and consumer group names of one worker.
|
|
20
|
+
Topics = Struct.new(:jobs, :retry, :status, :logs, :heartbeats, :registrations, :dlq, :group, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
# The worker id as it appears in topic and group names: lower case, with every character
|
|
23
|
+
# outside a-z 0-9 . _ - replaced by "_".
|
|
24
|
+
def self.worker_segment(worker_id)
|
|
25
|
+
worker_id.to_s.each_char.map do |char|
|
|
26
|
+
lower = char.downcase
|
|
27
|
+
lower.match?(/\A[a-z0-9._-]\z/) ? lower : "_"
|
|
28
|
+
end.join
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.topics(prefix, worker_id)
|
|
32
|
+
worker = worker_segment(worker_id)
|
|
33
|
+
|
|
34
|
+
Topics.new(
|
|
35
|
+
jobs: "#{prefix}milvaion.jobs.#{worker}",
|
|
36
|
+
retry: "#{prefix}milvaion.jobs.#{worker}.retry",
|
|
37
|
+
status: "#{prefix}milvaion.status",
|
|
38
|
+
logs: "#{prefix}milvaion.logs",
|
|
39
|
+
heartbeats: "#{prefix}milvaion.heartbeats",
|
|
40
|
+
registrations: "#{prefix}milvaion.registrations",
|
|
41
|
+
dlq: "#{prefix}milvaion.dlq",
|
|
42
|
+
group: "#{prefix}milvaion.worker.#{worker}"
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Record headers as the processor reads them: string keys and string values, the last of
|
|
47
|
+
# repeated headers winning (rdkafka returns an Array for a repeated header).
|
|
48
|
+
def self.decode_headers(headers)
|
|
49
|
+
(headers || {}).to_h do |key, value|
|
|
50
|
+
value = value.last if value.is_a?(Array)
|
|
51
|
+
[key.to_s, value.nil? ? nil : value.to_s]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Headers as UTF-8 strings, numbers as decimal strings, in a stable order.
|
|
56
|
+
def self.encode_headers(headers)
|
|
57
|
+
(headers || {}).sort_by { |key, _| key.to_s }.to_h do |key, value|
|
|
58
|
+
[key.to_s, header_value(value)]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.header_value(value)
|
|
63
|
+
case value
|
|
64
|
+
when nil then ""
|
|
65
|
+
when String then value.dup.force_encoding(Encoding::UTF_8)
|
|
66
|
+
else value.to_s
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# x-not-before as a Time, or nil when the header is missing or invalid.
|
|
71
|
+
def self.not_before(headers)
|
|
72
|
+
raw = (headers || {})[HEADER_NOT_BEFORE].to_s.strip
|
|
73
|
+
return nil unless raw.match?(/\A-?\d+\z/)
|
|
74
|
+
|
|
75
|
+
Time.at(Integer(raw, 10) / 1000r)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.unix_ms(time)
|
|
79
|
+
(time.to_r * 1000).floor
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Kafka settings with every default applied, validated. Built by WorkerOptions.
|
|
83
|
+
class Settings
|
|
84
|
+
KEYS = %i[brokers tls tls_ca_file tls_cert_file tls_key_file sasl_mechanism sasl_username sasl_password
|
|
85
|
+
topic_prefix auto_create_topics num_partitions replication_factor max_in_flight_per_partition].freeze
|
|
86
|
+
|
|
87
|
+
attr_reader(*KEYS)
|
|
88
|
+
|
|
89
|
+
def initialize(options, max_parallel_jobs)
|
|
90
|
+
options = (options || {}).to_h.transform_keys(&:to_sym)
|
|
91
|
+
unknown = options.keys - KEYS
|
|
92
|
+
raise ArgumentError, "Unknown Kafka option(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
93
|
+
|
|
94
|
+
@brokers = Array(options[:brokers]).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
95
|
+
@brokers = ["localhost:9092"] if @brokers.empty?
|
|
96
|
+
|
|
97
|
+
@topic_prefix = options[:topic_prefix].to_s
|
|
98
|
+
unless TOPIC_PREFIX_PATTERN.match?(@topic_prefix)
|
|
99
|
+
raise ArgumentError, "Kafka topic_prefix may only contain a-z A-Z 0-9 . _ -, got #{@topic_prefix.inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@auto_create_topics = options.fetch(:auto_create_topics, true) != false
|
|
103
|
+
@num_partitions = integer(options[:num_partitions], 12, "num_partitions")
|
|
104
|
+
raise ArgumentError, "Kafka num_partitions must be positive, got #{@num_partitions}" unless @num_partitions.between?(1, 1 << 20)
|
|
105
|
+
|
|
106
|
+
@replication_factor = integer(options[:replication_factor], -1, "replication_factor")
|
|
107
|
+
unless @replication_factor == -1 || @replication_factor.between?(1, 32_767)
|
|
108
|
+
raise ArgumentError, "Kafka replication_factor must be -1 or positive, got #{@replication_factor}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@max_in_flight_per_partition = integer(options[:max_in_flight_per_partition], max_parallel_jobs, "max_in_flight_per_partition")
|
|
112
|
+
unless @max_in_flight_per_partition.positive?
|
|
113
|
+
raise ArgumentError, "Kafka max_in_flight_per_partition must be positive, got #{@max_in_flight_per_partition}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
resolve_tls(options)
|
|
117
|
+
resolve_sasl(options)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def tls?
|
|
121
|
+
@tls
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def sasl?
|
|
125
|
+
!@sasl_mechanism.nil?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# librdkafka settings shared by the producer, the admin client and the consumer.
|
|
129
|
+
def client_config(client_id)
|
|
130
|
+
config = {
|
|
131
|
+
"bootstrap.servers": brokers.join(","),
|
|
132
|
+
"client.id": client_id,
|
|
133
|
+
"security.protocol": security_protocol
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
config[:"ssl.ca.location"] = tls_ca_file if tls_ca_file
|
|
137
|
+
config[:"ssl.certificate.location"] = tls_cert_file if tls_cert_file
|
|
138
|
+
config[:"ssl.key.location"] = tls_key_file if tls_key_file
|
|
139
|
+
|
|
140
|
+
if sasl?
|
|
141
|
+
config[:"sasl.mechanisms"] = sasl_mechanism
|
|
142
|
+
config[:"sasl.username"] = sasl_username
|
|
143
|
+
config[:"sasl.password"] = sasl_password.to_s
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
config
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def security_protocol
|
|
150
|
+
if sasl?
|
|
151
|
+
tls? ? "sasl_ssl" : "sasl_plaintext"
|
|
152
|
+
else
|
|
153
|
+
tls? ? "ssl" : "plaintext"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def integer(value, default, name)
|
|
160
|
+
return default if value.nil? || value.to_s.strip.empty? || value == 0
|
|
161
|
+
|
|
162
|
+
Integer(value)
|
|
163
|
+
rescue ArgumentError, TypeError
|
|
164
|
+
raise ArgumentError, "Kafka #{name} must be an integer, got #{value.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def resolve_tls(options)
|
|
168
|
+
@tls_ca_file = presence(options[:tls_ca_file])
|
|
169
|
+
@tls_cert_file = presence(options[:tls_cert_file])
|
|
170
|
+
@tls_key_file = presence(options[:tls_key_file])
|
|
171
|
+
@tls = (options[:tls] ? true : false) || !(@tls_ca_file || @tls_cert_file || @tls_key_file).nil?
|
|
172
|
+
|
|
173
|
+
if @tls_cert_file.nil? != @tls_key_file.nil?
|
|
174
|
+
raise ArgumentError, "Kafka tls_cert_file and tls_key_file must be set together"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
{ tls_ca_file: @tls_ca_file, tls_cert_file: @tls_cert_file, tls_key_file: @tls_key_file }.each do |name, path|
|
|
178
|
+
raise ArgumentError, "Kafka #{name} #{path.inspect} cannot be read" if path && !File.readable?(path)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def resolve_sasl(options)
|
|
183
|
+
raw = options[:sasl_mechanism].to_s.strip
|
|
184
|
+
@sasl_username = presence(options[:sasl_username])
|
|
185
|
+
@sasl_password = options[:sasl_password]
|
|
186
|
+
@sasl_mechanism = nil
|
|
187
|
+
return if raw.empty?
|
|
188
|
+
|
|
189
|
+
@sasl_mechanism = SASL_MECHANISMS[raw.downcase.delete("-_")]
|
|
190
|
+
|
|
191
|
+
if @sasl_mechanism.nil?
|
|
192
|
+
raise ArgumentError, %(Kafka sasl_mechanism must be "plain", "scram-sha-256" or "scram-sha-512", got #{raw.inspect})
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
raise ArgumentError, "Kafka sasl_username is required with sasl_mechanism" if @sasl_username.nil?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def presence(value)
|
|
199
|
+
value = value.to_s.strip
|
|
200
|
+
value.empty? ? nil : value
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
ENV_STRINGS = {
|
|
205
|
+
"MILVAION_KAFKA_TLS_CA_FILE" => :tls_ca_file,
|
|
206
|
+
"MILVAION_KAFKA_TLS_CERT_FILE" => :tls_cert_file,
|
|
207
|
+
"MILVAION_KAFKA_TLS_KEY_FILE" => :tls_key_file,
|
|
208
|
+
"MILVAION_KAFKA_SASL_MECHANISM" => :sasl_mechanism,
|
|
209
|
+
"MILVAION_KAFKA_SASL_USERNAME" => :sasl_username,
|
|
210
|
+
"MILVAION_KAFKA_SASL_PASSWORD" => :sasl_password,
|
|
211
|
+
"MILVAION_KAFKA_TOPIC_PREFIX" => :topic_prefix
|
|
212
|
+
}.freeze
|
|
213
|
+
|
|
214
|
+
ENV_INTEGERS = {
|
|
215
|
+
"MILVAION_KAFKA_NUM_PARTITIONS" => :num_partitions,
|
|
216
|
+
"MILVAION_KAFKA_PARTITIONS" => :num_partitions,
|
|
217
|
+
"MILVAION_KAFKA_REPLICATION_FACTOR" => :replication_factor,
|
|
218
|
+
"MILVAION_KAFKA_MAX_IN_FLIGHT_PER_PARTITION" => :max_in_flight_per_partition
|
|
219
|
+
}.freeze
|
|
220
|
+
|
|
221
|
+
# The Kafka options found in MILVAION_KAFKA_* variables, as a Hash for WorkerOptions' kafka:.
|
|
222
|
+
def self.options_from_env(env)
|
|
223
|
+
read = ->(key) { env[key].to_s.strip }
|
|
224
|
+
values = {}
|
|
225
|
+
|
|
226
|
+
brokers = read.call("MILVAION_KAFKA_BOOTSTRAP_SERVERS")
|
|
227
|
+
brokers = read.call("KAFKA_BOOTSTRAP_SERVERS") if brokers.empty?
|
|
228
|
+
values[:brokers] = brokers unless brokers.empty?
|
|
229
|
+
|
|
230
|
+
values[:tls] = true if read.call("MILVAION_KAFKA_TLS").casecmp?("true")
|
|
231
|
+
|
|
232
|
+
ENV_STRINGS.each do |key, name|
|
|
233
|
+
value = read.call(key)
|
|
234
|
+
values[name] = value unless value.empty?
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
values[:auto_create_topics] = false if read.call("MILVAION_KAFKA_AUTO_CREATE_TOPICS").casecmp?("false")
|
|
238
|
+
|
|
239
|
+
ENV_INTEGERS.each do |key, name|
|
|
240
|
+
raw = read.call(key)
|
|
241
|
+
next if raw.empty? || values.key?(name)
|
|
242
|
+
|
|
243
|
+
begin
|
|
244
|
+
values[name] = Integer(raw, 10)
|
|
245
|
+
rescue ArgumentError
|
|
246
|
+
raise ArgumentError, "#{key} must be a number, got #{raw.inspect}"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
values
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Follows the records of one partition that this instance has taken, in offset order, and knows
|
|
254
|
+
# how far the partition may be committed: up to the first record that is not finished, because
|
|
255
|
+
# a Kafka commit covers every record before it.
|
|
256
|
+
class OffsetTracker
|
|
257
|
+
def initialize
|
|
258
|
+
@pending = [] # [offset, done] pairs in offset order
|
|
259
|
+
@last = -1
|
|
260
|
+
@position = -1
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Registers a record taken from the partition. A repeated or older offset returns false.
|
|
264
|
+
def add(offset)
|
|
265
|
+
return false if offset <= @last
|
|
266
|
+
|
|
267
|
+
@last = offset
|
|
268
|
+
@pending << [offset, false]
|
|
269
|
+
true
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Marks a record finished. Returns false for an offset the tracker does not hold.
|
|
273
|
+
def done(offset)
|
|
274
|
+
index = @pending.bsearch_index { |(o, _)| o >= offset }
|
|
275
|
+
return false if index.nil? || @pending[index][0] != offset
|
|
276
|
+
|
|
277
|
+
@pending[index][1] = true
|
|
278
|
+
|
|
279
|
+
while (head = @pending.first) && head[1]
|
|
280
|
+
@position = head[0] + 1
|
|
281
|
+
@pending.shift
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
true
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The offset to commit, one past the end of the contiguous finished range, or nil until the
|
|
288
|
+
# first record has finished.
|
|
289
|
+
def commit_position
|
|
290
|
+
@position.negative? ? nil : @position
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Records taken that are not ready to commit yet.
|
|
294
|
+
def outstanding
|
|
295
|
+
@pending.size
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|