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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 690ac8cdb6818d2ee65b491eee4415b102d57b8ad48a90b03655f68cde194b90
4
- data.tar.gz: 66e2b832f777e3dec76371caedcbbb9cedcdf25e854cacd52c815836b8af32e5
3
+ metadata.gz: 1033916061253c1a73d22565a6f32702505cd8f623a219e07954e9107a685fcf
4
+ data.tar.gz: b89bafd296550ad1f2b25465af782d87be5b4979b18dabfa601ee918770ee8e3
5
5
  SHA512:
6
- metadata.gz: 793e35c5741b0f1be61f4154dd3796bf2554ba229c9ea0baafc85b2b5d54b9a3198dfa8c4fd7bebe19bd7439a6e82e4e0218ff6dbc4b95cae592a94c7854e67f
7
- data.tar.gz: aeaccc8157b3a95d6237a1db1561a5b9c61f1e2f52bc635a761f27889a7e860f4d15a1056b676ded1c6ce6828266531be92ba2a0a98d436c68d2f08f0b4346c6
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.0"
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